mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
add server
This commit is contained in:
parent
2b78dbe472
commit
0c56ac9f37
28
server/auth-realtime/.gitignore
vendored
Normal file
28
server/auth-realtime/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# dev
|
||||
.yarn/
|
||||
!.yarn/releases
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/*.code-snippets
|
||||
.idea/workspace.xml
|
||||
.idea/usage.statistics.xml
|
||||
.idea/shelf
|
||||
|
||||
# deps
|
||||
node_modules/
|
||||
|
||||
# env
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
5
server/auth-realtime/.prettierignore
Normal file
5
server/auth-realtime/.prettierignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
dist
|
||||
public
|
||||
|
||||
*.md
|
||||
*.mdx
|
||||
2
server/auth-realtime/Makefile
Normal file
2
server/auth-realtime/Makefile
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
build:
|
||||
npm run docker-build
|
||||
30
server/auth-realtime/docker/Dockerfile
Normal file
30
server/auth-realtime/docker/Dockerfile
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# inspired by: https://hono.dev/docs/getting-started/nodejs#dockerfile
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
RUN apk add --no-cache gcompat
|
||||
WORKDIR /server
|
||||
|
||||
COPY package*json tsconfig.json src ./
|
||||
COPY patches ./patches/
|
||||
|
||||
RUN npm ci && \
|
||||
npm run build && \
|
||||
npm prune --production
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /server
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 hono
|
||||
|
||||
COPY --from=builder --chown=hono:nodejs /server/node_modules /server/node_modules
|
||||
COPY --from=builder --chown=hono:nodejs /server/dist /server/dist
|
||||
COPY --from=builder --chown=hono:nodejs /server/package.json /server/package.json
|
||||
|
||||
ENV NODE_ENV=production
|
||||
USER hono
|
||||
EXPOSE 13321
|
||||
|
||||
CMD ["node", "--import", "/server/dist/instrument.js", "/server/dist/index.js"]
|
||||
19
server/auth-realtime/eslint.config.mjs
Normal file
19
server/auth-realtime/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// @ts-check
|
||||
|
||||
import eslintConfigPrettier from "eslint-config-prettier/flat"
|
||||
import eslint from "@eslint/js"
|
||||
import tseslint from "typescript-eslint"
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.strict,
|
||||
tseslint.configs.stylistic,
|
||||
eslintConfigPrettier,
|
||||
{
|
||||
rules: {
|
||||
"no-tabs": "off",
|
||||
indent: ["off"], // clashes with prettier
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
},
|
||||
)
|
||||
5345
server/auth-realtime/package-lock.json
generated
Normal file
5345
server/auth-realtime/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
64
server/auth-realtime/package.json
Normal file
64
server/auth-realtime/package.json
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"name": "node",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"dev": "tsx --import ./src/instrument.ts watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node --import ./dist/instrument.js dist/index.js",
|
||||
"docker-build": "docker buildx build --platform linux/amd64 --no-cache -t ghcr.io/oxynote/heimdall-guard:dev -f ./docker/Dockerfile .",
|
||||
"check-types": "tsc --noEmit",
|
||||
"lint": "eslint --fix .",
|
||||
"check-lint": "eslint .",
|
||||
"fmt": "prettier --write .",
|
||||
"check-fmt": "prettier --check .",
|
||||
"qa": "npm run check-types && npm run check-lint && npm run check-fmt",
|
||||
"qa-fix": "npm run check-types && npm run lint && npm run fmt"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/electron": "^1.6.11",
|
||||
"@hocuspocus/extension-logger": "^3.2.3",
|
||||
"@hocuspocus/server": "^3.2.2",
|
||||
"@hocuspocus/transformer": "^3.2.3",
|
||||
"@hono/node-server": "^1.19.0",
|
||||
"@hono/node-ws": "^1.2.0",
|
||||
"@sentry/node": "^10.39.0",
|
||||
"@tiptap/core": "^3.1.0",
|
||||
"@tiptap/extension-blockquote": "^3.1.0",
|
||||
"@tiptap/extension-bold": "^3.1.0",
|
||||
"@tiptap/extension-code": "^3.1.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.3.0",
|
||||
"@tiptap/extension-document": "^3.1.0",
|
||||
"@tiptap/extension-heading": "^3.1.0",
|
||||
"@tiptap/extension-horizontal-rule": "^3.1.0",
|
||||
"@tiptap/extension-italic": "^3.1.0",
|
||||
"@tiptap/extension-link": "^3.1.0",
|
||||
"@tiptap/extension-list": "^3.2.0",
|
||||
"@tiptap/extension-paragraph": "^3.1.0",
|
||||
"@tiptap/extension-strike": "^3.1.0",
|
||||
"@tiptap/extension-text": "^3.1.0",
|
||||
"@tiptap/extension-underline": "^3.1.0",
|
||||
"@tiptap/extension-unique-id": "^3.14.0",
|
||||
"axios": "^1.11.0",
|
||||
"better-auth": "^1.6.11",
|
||||
"hono": "^4.9.2",
|
||||
"kysely": "^0.28.8",
|
||||
"lowlight": "^3.3.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"pg": "^8.16.3",
|
||||
"redis": "^5.8.3",
|
||||
"yjs": "^13.6.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@types/node": "^20.11.17",
|
||||
"@types/pg": "^8.15.5",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"patch-package": "^8.0.1",
|
||||
"prettier": "3.6.2",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.40.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
diff --git a/node_modules/@better-auth/electron/dist/index.mjs b/node_modules/@better-auth/electron/dist/index.mjs
|
||||
index 4b33041..40c0a5f 100644
|
||||
--- a/node_modules/@better-auth/electron/dist/index.mjs
|
||||
+++ b/node_modules/@better-auth/electron/dist/index.mjs
|
||||
@@ -12,7 +12,7 @@ import { safeJSONParse as safeJSONParse$1 } from "@better-auth/core/utils/json";
|
||||
import { createHash } from "@better-auth/utils/hash";
|
||||
import { betterFetch } from "@better-fetch/fetch";
|
||||
import { createAuthEndpoint, sessionMiddleware } from "better-auth/api";
|
||||
-import { setSessionCookie } from "better-auth/cookies";
|
||||
+import { parseSetCookieHeader, setSessionCookie, toCookieOptions } from "better-auth/cookies";
|
||||
import { parseUserOutput } from "better-auth/db";
|
||||
//#region src/error-codes.ts
|
||||
const ELECTRON_ERROR_CODES = defineErrorCodes({
|
||||
@@ -121,7 +121,7 @@ const electronInitOAuthProxy = (opts) => createAuthEndpoint("/electron/init-oaut
|
||||
if (!isSocialProvider && !ctx.context.getPlugin("generic-oauth")) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PROVIDER_NOT_FOUND);
|
||||
const headers = new Headers(ctx.request?.headers);
|
||||
headers.set("origin", new URL(ctx.context.baseURL).origin);
|
||||
- let setCookie = null;
|
||||
+ let setCookies = [];
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("client_id", opts.clientID || "electron");
|
||||
searchParams.set("code_challenge", ctx.query.code_challenge);
|
||||
@@ -131,13 +131,15 @@ const electronInitOAuthProxy = (opts) => createAuthEndpoint("/electron/init-oaut
|
||||
baseURL: ctx.context.baseURL,
|
||||
method: "POST",
|
||||
body: { provider: ctx.query.provider },
|
||||
- onResponse: (ctx) => {
|
||||
- setCookie = ctx.response.headers.get("set-cookie") ?? null;
|
||||
+ onResponse: (innerCtx) => {
|
||||
+ setCookies = innerCtx.response.headers.getSetCookie();
|
||||
},
|
||||
headers
|
||||
});
|
||||
if (res.error) throw new APIError("INTERNAL_SERVER_ERROR", { message: res.error.message || "An unknown error occurred." });
|
||||
- if (setCookie) ctx.setHeader("set-cookie", setCookie);
|
||||
+ for (const cookieStr of setCookies) parseSetCookieHeader(cookieStr).forEach((attrs, name) => {
|
||||
+ ctx.setCookie(name, attrs.value, toCookieOptions(attrs));
|
||||
+ });
|
||||
if (res.data.url && res.data.redirect) {
|
||||
ctx.setHeader("Location", res.data.url);
|
||||
ctx.setStatus(302);
|
||||
6
server/auth-realtime/prettier.config.js
Normal file
6
server/auth-realtime/prettier.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
trailingComma: "all",
|
||||
useTabs: true,
|
||||
tabWidth: 8,
|
||||
semi: false,
|
||||
}
|
||||
11
server/auth-realtime/sql/better_auth_schema.sql
Normal file
11
server/auth-realtime/sql/better_auth_schema.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
create table "users" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" boolean not null, "image" text, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz default CURRENT_TIMESTAMP not null);
|
||||
|
||||
create table "user_accounts" ("id" text not null primary key, "accountId" text not null, "providerId" text not null, "userId" text not null references "users" ("id") on delete cascade, "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" timestamptz, "refreshTokenExpiresAt" timestamptz, "scope" text, "password" text, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz not null);
|
||||
|
||||
create table "user_verifications" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" timestamptz not null, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz default CURRENT_TIMESTAMP not null);
|
||||
|
||||
create table "organizations" ("id" text not null primary key, "name" text not null, "slug" text not null unique, "logo" text, "createdAt" timestamptz not null, "metadata" text);
|
||||
|
||||
create table "organization_members" ("id" text not null primary key, "organizationId" text not null references "organizations" ("id") on delete cascade, "userId" text not null references "users" ("id") on delete cascade, "role" text not null, "createdAt" timestamptz not null);
|
||||
|
||||
create table "organization_invitations" ("id" text not null primary key, "organizationId" text not null references "organizations" ("id") on delete cascade, "email" text not null, "role" text, "status" text not null, "expiresAt" timestamptz not null, "inviterId" text not null references "users" ("id") on delete cascade);
|
||||
470
server/auth-realtime/src/auth.ts
Normal file
470
server/auth-realtime/src/auth.ts
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
import * as Sentry from "@sentry/node"
|
||||
import { betterAuth } from "better-auth"
|
||||
import { createAuthMiddleware, APIError } from "better-auth/api"
|
||||
import { organization } from "better-auth/plugins"
|
||||
import { electron } from "@better-auth/electron";
|
||||
import { createClient } from "redis"
|
||||
import { db, dialect } from "./database.js"
|
||||
import axios from "axios"
|
||||
|
||||
const MAX_ORGANIZATION_MEMBERS = parseInt(
|
||||
process.env.HEIMDALL_GUARD_MAX_ORGANIZATION_MEMBERS || "5",
|
||||
)
|
||||
export const MAX_ORGANIZATIONS = parseInt(
|
||||
process.env.HEIMDALL_GUARD_MAX_ORGANIZATIONS || "100",
|
||||
)
|
||||
const backendUrl = process.env.HEIMDALL_GUARD_BACKEND_URL
|
||||
|
||||
const ALLOWED_EMAILS = new Set(
|
||||
(process.env.HEIMDALL_GUARD_ALLOWED_EMAILS || "")
|
||||
.split(",")
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
const FRONTEND_URL = process.env.HEIMDALL_GUARD_FRONTEND_URL as string
|
||||
|
||||
function isEmailAllowed(email: string): boolean {
|
||||
if (ALLOWED_EMAILS.size === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return ALLOWED_EMAILS.has(email.toLowerCase())
|
||||
}
|
||||
|
||||
const redisClient = createClient({
|
||||
url: process.env.HEIMDALL_GUARD_VALKEY_URL,
|
||||
})
|
||||
|
||||
await redisClient.connect()
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: process.env.HEIMDALL_GUARD_BETTER_AUTH_BASE_URL,
|
||||
secret: process.env.HEIMDALL_GUARD_BETTER_AUTH_SECRET,
|
||||
database: dialect,
|
||||
socialProviders: {
|
||||
slack: {
|
||||
clientId: process.env
|
||||
.HEIMDALL_GUARD_BETTER_AUTH_SLACK_CLIENT_ID as string,
|
||||
clientSecret: process.env
|
||||
.HEIMDALL_GUARD_BETTER_AUTH_SLACK_CLIENT_SECRET as string,
|
||||
redirectURI:
|
||||
(process.env
|
||||
.HEIMDALL_GUARD_BETTER_AUTH_BASE_URL as string) +
|
||||
"/api/auth/callback/slack",
|
||||
},
|
||||
google: {
|
||||
clientId: process.env
|
||||
.HEIMDALL_GUARD_BETTER_AUTH_GOOGLE_CLIENT_ID as string,
|
||||
clientSecret: process.env
|
||||
.HEIMDALL_GUARD_BETTER_AUTH_GOOGLE_CLIENT_SECRET as string,
|
||||
redirectURI:
|
||||
(process.env
|
||||
.HEIMDALL_GUARD_BETTER_AUTH_BASE_URL as string) +
|
||||
"/api/auth/callback/google",
|
||||
},
|
||||
github: {
|
||||
clientId: process.env
|
||||
.HEIMDALL_GUARD_BETTER_AUTH_GITHUB_CLIENT_ID as string,
|
||||
clientSecret: process.env
|
||||
.HEIMDALL_GUARD_BETTER_AUTH_GITHUB_CLIENT_SECRET as string,
|
||||
redirectURI:
|
||||
(process.env
|
||||
.HEIMDALL_GUARD_BETTER_AUTH_BASE_URL as string) +
|
||||
"/api/auth/callback/github",
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
cookiePrefix: "auth",
|
||||
crossSubDomainCookies: {
|
||||
enabled: true,
|
||||
domain: process.env
|
||||
.HEIMDALL_GUARD_BETTER_AUTH_COOKIE_DOMAIN as string,
|
||||
},
|
||||
},
|
||||
secondaryStorage: {
|
||||
get: async (key) => {
|
||||
try {
|
||||
return await redisClient.get(key)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
set: async (key, value, ttl) => {
|
||||
try {
|
||||
if (ttl) {
|
||||
await redisClient.set(key, value, { EX: ttl })
|
||||
return
|
||||
}
|
||||
|
||||
await redisClient.set(key, value)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
delete: async (key) => {
|
||||
try {
|
||||
await redisClient.del(key)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
},
|
||||
trustedOrigins: (
|
||||
(process.env.HEIMDALL_GUARD_TRUSTED_ORIGINS || "") as string
|
||||
).split(","),
|
||||
user: {
|
||||
modelName: "users",
|
||||
fields: {
|
||||
emailVerified: "email_verified",
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
},
|
||||
changeEmail: {
|
||||
enabled: true,
|
||||
sendChangeEmailConfirmation: async ({
|
||||
newEmail,
|
||||
url,
|
||||
}) => {
|
||||
await sendEmailVerification(newEmail, url)
|
||||
},
|
||||
},
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
sendDeleteAccountVerification: async ({
|
||||
user,
|
||||
url,
|
||||
}) => {
|
||||
await sendUserDeletionConfirmation(
|
||||
user.email,
|
||||
url,
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
account: {
|
||||
modelName: "user_accounts",
|
||||
fields: {
|
||||
accountId: "account_id",
|
||||
providerId: "provider_id",
|
||||
userId: "fk_user_id",
|
||||
accessToken: "access_token",
|
||||
refreshToken: "refresh_token",
|
||||
idToken: "id_token",
|
||||
accessTokenExpiresAt: "access_token_expires_at",
|
||||
refreshTokenExpiresAt: "refresh_token_expires_at",
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
},
|
||||
},
|
||||
session: {
|
||||
modelName: "user_sessions",
|
||||
storeSessionInDatabase: false,
|
||||
},
|
||||
verification: {
|
||||
modelName: "user_verifications",
|
||||
fields: {
|
||||
expiresAt: "expires_at",
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
electron(),
|
||||
organization({
|
||||
allowUserToCreateOrganization: async () => {
|
||||
try {
|
||||
const count = await totalOrganizationCount()
|
||||
return count < MAX_ORGANIZATIONS
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
disableOrganizationDeletion: true, // prevent clients from deleting the org
|
||||
organizationLimit: 1,
|
||||
membershipLimit: MAX_ORGANIZATION_MEMBERS,
|
||||
schema: {
|
||||
organization: {
|
||||
modelName: "organizations",
|
||||
fields: {
|
||||
createdAt: "created_at",
|
||||
},
|
||||
},
|
||||
member: {
|
||||
modelName: "organization_members",
|
||||
fields: {
|
||||
organizationId:
|
||||
"fk_organization_id",
|
||||
userId: "fk_user_id",
|
||||
createdAt: "created_at",
|
||||
},
|
||||
},
|
||||
invitation: {
|
||||
modelName: "organization_invitations",
|
||||
fields: {
|
||||
organizationId:
|
||||
"fk_organization_id",
|
||||
expiresAt: "expires_at",
|
||||
inviterId: "fk_inviter_id",
|
||||
},
|
||||
},
|
||||
},
|
||||
organizationHooks: {
|
||||
afterCreateOrganization: async ({
|
||||
organization,
|
||||
}) => {
|
||||
await initializeOrganization(
|
||||
organization.id,
|
||||
)
|
||||
},
|
||||
},
|
||||
sendInvitationEmail: async (data) => {
|
||||
const inviteLink =
|
||||
process.env
|
||||
.HEIMDALL_GUARD_BETTER_AUTH_ORGANIZATION_INVITATION_URL +
|
||||
`?id=${data.id}` +
|
||||
`&email=${data.email}` +
|
||||
`&inviter=${data.inviter.user.name}` +
|
||||
`&orgName=${data.organization.name}` +
|
||||
`&orgId=${data.organization.id}`
|
||||
|
||||
await sendOrganizationInvitation(
|
||||
data.email,
|
||||
data.organization.name,
|
||||
inviteLink,
|
||||
)
|
||||
},
|
||||
}),
|
||||
],
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
// The @better-auth/electron plugin proxies /electron/init-oauth-proxy
|
||||
// to /sign-in/social with only `{ provider }` in the body, so
|
||||
// generateState() falls back to `options.baseURL` for callbackURL and
|
||||
// the post-callback 302 lands on the heimdall root (404). Send it to
|
||||
// the desktop-auth handoff page on the frontend instead — that page
|
||||
// reads the auth-code cookie set on /callback/* and triggers the
|
||||
// deep link back into the desktop app.
|
||||
if (
|
||||
ctx.path === "/sign-in/social" &&
|
||||
ctx.query?.client_id === "electron" &&
|
||||
!ctx.body?.callbackURL
|
||||
) {
|
||||
return {
|
||||
context: {
|
||||
body: {
|
||||
...ctx.body,
|
||||
callbackURL: `${FRONTEND_URL}/desktop-auth`,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}),
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
if (!ctx.path.startsWith("/callback/")) {
|
||||
return
|
||||
}
|
||||
|
||||
const setCookie =
|
||||
ctx.context.responseHeaders?.get("set-cookie")
|
||||
if (!setCookie) {
|
||||
return
|
||||
}
|
||||
|
||||
const match = setCookie.match(
|
||||
/auth\.session_token=([^;,]+)/,
|
||||
)
|
||||
if (!match?.[1]) {
|
||||
return
|
||||
}
|
||||
|
||||
const signedToken = decodeURIComponent(match[1])
|
||||
const token = signedToken.substring(
|
||||
0,
|
||||
signedToken.lastIndexOf("."),
|
||||
)
|
||||
if (!token) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionData = await redisClient.get(token)
|
||||
if (!sessionData) {
|
||||
return
|
||||
}
|
||||
|
||||
const { user } = JSON.parse(sessionData)
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEmailAllowed(user.email)) {
|
||||
await redisClient.del(token)
|
||||
|
||||
const isNewUser =
|
||||
Date.now() -
|
||||
new Date(user.createdAt).getTime() <
|
||||
10_000
|
||||
|
||||
const errorCode = isNewUser
|
||||
? "auth.registration_not_allowed"
|
||||
: "auth.login_not_allowed"
|
||||
|
||||
const redirectHeaders = new Headers()
|
||||
redirectHeaders.set(
|
||||
"location",
|
||||
`${FRONTEND_URL}/login?error=${errorCode}`,
|
||||
)
|
||||
throw new APIError("FOUND", undefined, redirectHeaders)
|
||||
}
|
||||
}),
|
||||
},
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
await sendUserCreation(user.email)
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
create: {
|
||||
before: async (session) => {
|
||||
try {
|
||||
const orgId = await userOrganizationId(
|
||||
session.userId,
|
||||
)
|
||||
return {
|
||||
data: {
|
||||
...session,
|
||||
activeOrganizationId:
|
||||
orgId,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
},
|
||||
update: {
|
||||
before: async (data, ctx) => {
|
||||
try {
|
||||
if (ctx?.context.session) {
|
||||
const orgId =
|
||||
await userOrganizationId(
|
||||
ctx.context
|
||||
.session
|
||||
.user
|
||||
.id,
|
||||
)
|
||||
return {
|
||||
data: {
|
||||
...data,
|
||||
activeOrganizationId:
|
||||
orgId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { data }
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
async function userOrganizationId(userId: string): Promise<string | null> {
|
||||
const res = await db
|
||||
.selectFrom("organization_members")
|
||||
.where("fk_user_id", "=", userId)
|
||||
.select("fk_organization_id")
|
||||
.executeTakeFirst()
|
||||
|
||||
return res ? res.fk_organization_id : null
|
||||
}
|
||||
|
||||
export async function totalOrganizationCount(): Promise<number> {
|
||||
const res = await db
|
||||
.selectFrom("organizations")
|
||||
.select(db.fn.countAll<number>().as("count"))
|
||||
.executeTakeFirstOrThrow()
|
||||
|
||||
return res.count
|
||||
}
|
||||
|
||||
async function initializeOrganization(organizationId: string): Promise<void> {
|
||||
try {
|
||||
await axios.post(
|
||||
`${backendUrl}/api/x/organizations/${organizationId}/initialize`,
|
||||
)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function sendEmailVerification(
|
||||
email: string,
|
||||
link: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await axios.post(`${backendUrl}/api/x/email/verification`, {
|
||||
email,
|
||||
link,
|
||||
})
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function sendOrganizationInvitation(
|
||||
email: string,
|
||||
organization: string,
|
||||
link: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await axios.post(`${backendUrl}/api/x/email/organization-invitation`, {
|
||||
email,
|
||||
organization,
|
||||
link,
|
||||
})
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function sendUserDeletionConfirmation(
|
||||
email: string,
|
||||
link: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await axios.post(`${backendUrl}/api/x/email/user-deletion`, {
|
||||
email,
|
||||
link,
|
||||
})
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function sendUserCreation(email: string): Promise<void> {
|
||||
try {
|
||||
await axios.post(`${backendUrl}/api/x/email/user-creation`, {
|
||||
email,
|
||||
})
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
32
server/auth-realtime/src/database.ts
Normal file
32
server/auth-realtime/src/database.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Pool } from 'pg'
|
||||
import { Kysely, PostgresDialect } from 'kysely'
|
||||
|
||||
export const dialect = new PostgresDialect({
|
||||
pool: new Pool({
|
||||
connectionString: process.env.HEIMDALL_GUARD_DB_DSN,
|
||||
})
|
||||
})
|
||||
|
||||
export const db = new Kysely<Database>({
|
||||
dialect,
|
||||
})
|
||||
|
||||
export interface Database {
|
||||
users: UsersTable
|
||||
organizations: OrganizationsTable
|
||||
organization_members: OrganizationMembersTable
|
||||
}
|
||||
|
||||
export interface UsersTable {
|
||||
id: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface OrganizationsTable {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface OrganizationMembersTable {
|
||||
fk_user_id: string
|
||||
fk_organization_id: string
|
||||
}
|
||||
354
server/auth-realtime/src/hocuspocus.ts
Normal file
354
server/auth-realtime/src/hocuspocus.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
import * as Sentry from "@sentry/node"
|
||||
import * as Y from "yjs"
|
||||
import axios from "axios"
|
||||
import { nanoid } from "nanoid"
|
||||
import { AxiosHeaders } from "axios"
|
||||
import { Hocuspocus } from "@hocuspocus/server"
|
||||
import { Logger } from "@hocuspocus/extension-logger"
|
||||
import { auth } from "./auth.js"
|
||||
import { replaceYdocContent, transformer } from "./ydocument.js"
|
||||
|
||||
const url = process.env.HEIMDALL_GUARD_BACKEND_URL
|
||||
|
||||
const documentMaintainers = new Map<string, Set<string>>()
|
||||
|
||||
// parseDocumentName splits a document name of the form "documentId-branchId"
|
||||
// into its component parts. branchId may be the special value "default".
|
||||
function parseDocumentName(documentName: string): {
|
||||
documentId: string
|
||||
branchIdentifier: string
|
||||
} {
|
||||
const idx = documentName.indexOf("-")
|
||||
return {
|
||||
documentId: documentName.slice(0, idx),
|
||||
branchIdentifier: documentName.slice(idx + 1),
|
||||
}
|
||||
}
|
||||
|
||||
// resolveBranchId resolves a branchIdentifier to a concrete branch ID.
|
||||
// When the identifier is "default", it fetches the document's branches and
|
||||
// returns the ID of the oldest one (the original branch).
|
||||
async function resolveBranchId(
|
||||
documentId: string,
|
||||
branchIdentifier: string,
|
||||
): Promise<string> {
|
||||
if (branchIdentifier !== "default") {
|
||||
return branchIdentifier
|
||||
}
|
||||
|
||||
const response = await axios.get(
|
||||
`${url}/api/x/documents/${documentId}/branches`,
|
||||
)
|
||||
|
||||
const branches: { branchId: string; default: boolean }[] = response.data
|
||||
|
||||
const defaultBranch = branches.find((b) => b.default)
|
||||
|
||||
if (!defaultBranch) {
|
||||
throw new Error(`no default branch found for document ${documentId}`)
|
||||
}
|
||||
|
||||
return defaultBranch.branchId
|
||||
}
|
||||
|
||||
export const hocuspocus = new Hocuspocus({
|
||||
extensions: [new Logger()],
|
||||
async onAuthenticate({ documentName, request, requestHeaders, token }) {
|
||||
const { documentId } = parseDocumentName(documentName)
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: toHeaders(request, requestHeaders),
|
||||
})
|
||||
if (!session || token === "force-error") {
|
||||
throw new Error("not authenticated")
|
||||
}
|
||||
|
||||
// Verify the user has access to this document by checking the oldest
|
||||
// branch. FetchDocumentBranchesUnsafe does not require an org filter —
|
||||
// the session check above confirms identity and the branch-list call
|
||||
// below confirms existence.
|
||||
try {
|
||||
await axios.get(
|
||||
`${url}/api/x/documents/${documentId}/branches`,
|
||||
{
|
||||
headers: toAxiosHeaders(requestHeaders),
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
|
||||
/* TODO check edit permissions
|
||||
if (!canEdit) {
|
||||
connectionConfig.readOnly = true // allow read but block edits
|
||||
}
|
||||
*/
|
||||
|
||||
return {
|
||||
session: session,
|
||||
}
|
||||
},
|
||||
// TODO kick users if auth becomes invalid later
|
||||
async beforeHandleMessage() {
|
||||
// periodically re-check better auth session and close the socket if
|
||||
// needed
|
||||
},
|
||||
async onChange({ context, documentName }) {
|
||||
const session = context?.session
|
||||
const userId = session?.user?.id
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
|
||||
let set = documentMaintainers.get(documentName)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
documentMaintainers.set(documentName, set)
|
||||
}
|
||||
|
||||
set.add(userId)
|
||||
},
|
||||
async onLoadDocument({ documentName }) {
|
||||
try {
|
||||
const { documentId, branchIdentifier } =
|
||||
parseDocumentName(documentName)
|
||||
const branchId = await resolveBranchId(
|
||||
documentId,
|
||||
branchIdentifier,
|
||||
)
|
||||
|
||||
const response = await axios.get(
|
||||
`${url}/api/x/documents/${documentId}/branch/${branchId}`,
|
||||
)
|
||||
|
||||
if (!response.data.rawContent) {
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
replaceYdocContent(ydoc, {
|
||||
name: response.data.documentName,
|
||||
content:
|
||||
response.data.content.type ==
|
||||
"doc"
|
||||
? response.data.content
|
||||
: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: {
|
||||
uid: nanoid(),
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Hello World!",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
icon: response.data.icon,
|
||||
})
|
||||
|
||||
// Persist rawContent immediately to prevent content duplication.
|
||||
// Without this, if the server restarts before onStoreDocument runs,
|
||||
// onLoadDocument would create a new Y.Doc with a different clientID,
|
||||
// causing the reconnecting client's existing state to merge with the
|
||||
// new state instead of being recognized as identical — duplicating
|
||||
// all content.
|
||||
const rawContent = Buffer.from(
|
||||
Y.encodeStateAsUpdate(ydoc),
|
||||
).toString("base64")
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`${url}/api/x/documents/${documentId}/branch/${branchId}`,
|
||||
{
|
||||
name: response.data.documentName,
|
||||
icon: response.data.icon,
|
||||
content: response.data.content,
|
||||
maintainers: [],
|
||||
rawContent,
|
||||
system: true,
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
}
|
||||
|
||||
return ydoc
|
||||
}
|
||||
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
const data = new Uint8Array(
|
||||
Buffer.from(response.data.rawContent, "base64"),
|
||||
)
|
||||
|
||||
Y.applyUpdate(ydoc, data)
|
||||
|
||||
return ydoc
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
async onStoreDocument(data) {
|
||||
try {
|
||||
const { documentId, branchIdentifier } =
|
||||
parseDocumentName(data.documentName)
|
||||
const branchId = await resolveBranchId(
|
||||
documentId,
|
||||
branchIdentifier,
|
||||
)
|
||||
|
||||
const maintainers = Array.from(
|
||||
documentMaintainers.get(data.documentName) ??
|
||||
[],
|
||||
)
|
||||
documentMaintainers.delete(data.documentName)
|
||||
|
||||
const name = transformer.fromYdoc(data.document, "name")
|
||||
.content[0]?.content[0]?.text ||
|
||||
"Untitled Document"
|
||||
|
||||
await axios.put(
|
||||
`${url}/api/x/documents/${documentId}/branch/${branchId}`,
|
||||
{
|
||||
name: name,
|
||||
icon: data.document.getText("icon").toString(),
|
||||
content: transformer.fromYdoc(
|
||||
data.document,
|
||||
"content",
|
||||
),
|
||||
maintainers: maintainers,
|
||||
rawContent: Buffer.from(
|
||||
Y.encodeStateAsUpdate(data.document),
|
||||
).toString("base64"),
|
||||
system: false,
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
|
||||
data.document.broadcastStateless(
|
||||
JSON.stringify({ type: "error", code: "hocuspocus.store_failed" }),
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type HeaderEntry = [string, string]
|
||||
|
||||
function normalizeHeaderValue(value: unknown): string | null {
|
||||
if (value == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Array.isArray(value) ? value.join(", ") : String(value)
|
||||
}
|
||||
|
||||
function collectHeaderEntries(requestHeaders: any): HeaderEntry[] | null {
|
||||
if (!requestHeaders) {
|
||||
return null
|
||||
}
|
||||
|
||||
// WHATWG Headers or compatible
|
||||
if (typeof requestHeaders.forEach === "function") {
|
||||
const entries: HeaderEntry[] = []
|
||||
requestHeaders.forEach((v: string, k: string) => {
|
||||
const value = normalizeHeaderValue(v)
|
||||
if (value != null) {
|
||||
entries.push([k, value])
|
||||
}
|
||||
})
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// Iterable header entries
|
||||
if (typeof requestHeaders[Symbol.iterator] === "function") {
|
||||
const entries: HeaderEntry[] = []
|
||||
for (const [k, v] of requestHeaders as any) {
|
||||
const value = normalizeHeaderValue(v)
|
||||
if (value != null) {
|
||||
entries.push([k, value])
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// Plain object map
|
||||
if (typeof requestHeaders === "object") {
|
||||
const entries: HeaderEntry[] = []
|
||||
for (const k of Reflect.ownKeys(requestHeaders) as string[]) {
|
||||
const value = normalizeHeaderValue(requestHeaders[k])
|
||||
if (value != null) {
|
||||
entries.push([k, value])
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function toHeaders(req: any, requestHeaders: any): Headers {
|
||||
const res = new Headers()
|
||||
const entries = collectHeaderEntries(requestHeaders)
|
||||
|
||||
if (entries) {
|
||||
for (const [k, v] of entries) {
|
||||
res.append(k, v)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// fallback: Node IncomingMessage.rawHeaders (always present on Node)
|
||||
if (req?.rawHeaders && Array.isArray(req.rawHeaders)) {
|
||||
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
||||
const k = String(req.rawHeaders[i])
|
||||
const v = String(req.rawHeaders[i + 1])
|
||||
res.append(k, v)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// last resort: regular req.headers
|
||||
if (req?.headers && typeof req.headers === "object") {
|
||||
for (const k of Reflect.ownKeys(req.headers) as string[]) {
|
||||
const v = req.headers[k as any]
|
||||
if (v != null)
|
||||
res.set(
|
||||
k,
|
||||
Array.isArray(v)
|
||||
? v.join(", ")
|
||||
: String(v),
|
||||
)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
export function toAxiosHeaders(requestHeaders: any): AxiosHeaders {
|
||||
const res = new AxiosHeaders()
|
||||
const entries = collectHeaderEntries(requestHeaders)
|
||||
|
||||
if (entries) {
|
||||
for (const [k, v] of entries) {
|
||||
res.set(k, v)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
54
server/auth-realtime/src/index.ts
Normal file
54
server/auth-realtime/src/index.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { serve } from "@hono/node-server"
|
||||
import { Hono } from "hono"
|
||||
import { createNodeWebSocket } from "@hono/node-ws"
|
||||
import { hocuspocus } from "./hocuspocus.js"
|
||||
import routes from "./routes.js"
|
||||
|
||||
const app = new Hono()
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
|
||||
|
||||
app.route("/api", routes)
|
||||
app.get(
|
||||
"/hocuspocus",
|
||||
upgradeWebSocket((c) => ({
|
||||
onOpen(_evt, ws) {
|
||||
hocuspocus.handleConnection(ws.raw, c.req.raw as any)
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
const server = serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port: 13321,
|
||||
},
|
||||
(info) => {
|
||||
hocuspocus.hooks("onListen", {
|
||||
instance: hocuspocus,
|
||||
configuration: hocuspocus.configuration,
|
||||
port: info.port,
|
||||
})
|
||||
|
||||
console.log(
|
||||
`Server is running on http://localhost:${info.port}`,
|
||||
)
|
||||
},
|
||||
)
|
||||
injectWebSocket(server)
|
||||
|
||||
// graceful shutdown
|
||||
process.on("SIGINT", () => {
|
||||
server.close()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
})
|
||||
})
|
||||
11
server/auth-realtime/src/instrument.ts
Normal file
11
server/auth-realtime/src/instrument.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import * as Sentry from "@sentry/node"
|
||||
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV ?? "development",
|
||||
tracesSampleRate: process.env.SENTRY_TRACES_SAMPLE_RATE
|
||||
? parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE)
|
||||
: 0,
|
||||
})
|
||||
}
|
||||
445
server/auth-realtime/src/operations.ts
Normal file
445
server/auth-realtime/src/operations.ts
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
/**
|
||||
* Operation interpreter for the Go-driven edit RPC.
|
||||
*
|
||||
* The Go assistant builds operations against the canonical block
|
||||
* model, expands them into ProseMirror JSON, and posts them to the
|
||||
* /api/x/documents/:docId/branches/:branchId/operations route. This
|
||||
* module owns the Y.XmlFragment mutations that apply those
|
||||
* operations to a live Y.Doc. Every applied batch runs inside a
|
||||
* single `Y.Doc.transact` so subscribers see one update.
|
||||
*
|
||||
* The operations themselves are intentionally small (single-block
|
||||
* atomic) because the AI fires them in parallel within one turn —
|
||||
* see docs/assistant-rework.md §2.1 for the schema.
|
||||
*/
|
||||
import * as Y from "yjs"
|
||||
import { nanoid } from "nanoid"
|
||||
import { transformer, cloneXmlElement } from "./ydocument.js"
|
||||
|
||||
/** Canonical attribute name carried on every editor block. */
|
||||
const UID_ATTR = "uid"
|
||||
|
||||
/**
|
||||
* One batched operation against a document. Shapes mirror the Go
|
||||
* canonical/op definitions; the Node side only validates the
|
||||
* minimum required to apply each op.
|
||||
*/
|
||||
export type Operation =
|
||||
| InsertOp
|
||||
| AppendOp
|
||||
| PrependOp
|
||||
| ReplaceOp
|
||||
| UpdateTextOp
|
||||
| UpdateAttrsOp
|
||||
| DeleteOp
|
||||
| SetNameOp
|
||||
| SetIconOp
|
||||
|
||||
/** Inserts a new block immediately before/after a referenced block. */
|
||||
export interface InsertOp {
|
||||
kind: "insert"
|
||||
/** Insertion side relative to the reference block. */
|
||||
position: "before" | "after"
|
||||
/** Reference block's uid in the document. */
|
||||
reference_uid: string
|
||||
/** New block as ProseMirror JSON, including a uid attribute. */
|
||||
block: PMNode
|
||||
}
|
||||
|
||||
/** Appends a new block at the end of the document root. */
|
||||
export interface AppendOp {
|
||||
kind: "append"
|
||||
block: PMNode
|
||||
}
|
||||
|
||||
/** Prepends a new block at the start of the document root. */
|
||||
export interface PrependOp {
|
||||
kind: "prepend"
|
||||
block: PMNode
|
||||
}
|
||||
|
||||
/** Replaces an existing block by uid with a new block. */
|
||||
export interface ReplaceOp {
|
||||
kind: "replace"
|
||||
block_uid: string
|
||||
block: PMNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the inline content of a text-bearing block in place,
|
||||
* preserving the block's type, attrs, and uid. Children that are
|
||||
* themselves blocks (e.g. paragraphs inside a callout) are not
|
||||
* affected.
|
||||
*/
|
||||
export interface UpdateTextOp {
|
||||
kind: "update_text"
|
||||
block_uid: string
|
||||
/** ProseMirror inline content (text nodes with marks). */
|
||||
content: PMInline[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets/overrides the named attributes on an existing block. Other
|
||||
* attributes are preserved. The uid attribute cannot be changed.
|
||||
*/
|
||||
export interface UpdateAttrsOp {
|
||||
kind: "update_attrs"
|
||||
block_uid: string
|
||||
attrs: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Removes a block from the document. */
|
||||
export interface DeleteOp {
|
||||
kind: "delete"
|
||||
block_uid: string
|
||||
}
|
||||
|
||||
/** Updates the document's display name. */
|
||||
export interface SetNameOp {
|
||||
kind: "set_name"
|
||||
name: string
|
||||
}
|
||||
|
||||
/** Updates the document's icon identifier. */
|
||||
export interface SetIconOp {
|
||||
kind: "set_icon"
|
||||
icon: string
|
||||
}
|
||||
|
||||
/** A ProseMirror JSON node. Loose shape; transformer validates. */
|
||||
export interface PMNode {
|
||||
type: string
|
||||
attrs?: Record<string, unknown>
|
||||
content?: (PMNode | PMInline)[]
|
||||
text?: string
|
||||
marks?: PMMark[]
|
||||
}
|
||||
|
||||
/** A ProseMirror inline text node fragment. */
|
||||
export interface PMInline {
|
||||
type: "text"
|
||||
text: string
|
||||
marks?: PMMark[]
|
||||
}
|
||||
|
||||
/** A ProseMirror mark (bold, italic, link, …). */
|
||||
export interface PMMark {
|
||||
type: string
|
||||
attrs?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** One operation's outcome in the batch response. */
|
||||
export interface OperationError {
|
||||
/** Index of the failing operation in the request payload. */
|
||||
index: number
|
||||
/** Short, machine-readable reason. */
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Result of applying a batch of operations. */
|
||||
export interface ApplyResult {
|
||||
/** Number of operations that were applied successfully. */
|
||||
applied: number
|
||||
/** Per-op errors, one entry per failed op (in input order). */
|
||||
errors: OperationError[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a batch of operations to the given Y.Doc inside a single
|
||||
* transaction. Failures on individual operations are collected but
|
||||
* do not abort the batch — applied state is committed for the ops
|
||||
* that succeeded. Subscribers see one consolidated update.
|
||||
*/
|
||||
export function applyOperations(doc: Y.Doc, ops: Operation[]): ApplyResult {
|
||||
const result: ApplyResult = { applied: 0, errors: [] }
|
||||
|
||||
doc.transact(() => {
|
||||
ops.forEach((op, index) => {
|
||||
try {
|
||||
applyOperation(doc, op)
|
||||
result.applied++
|
||||
} catch (err) {
|
||||
result.errors.push({
|
||||
index,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** Dispatches one operation to its concrete handler. */
|
||||
function applyOperation(doc: Y.Doc, op: Operation): void {
|
||||
switch (op.kind) {
|
||||
case "insert":
|
||||
return opInsert(doc, op)
|
||||
case "append":
|
||||
return opAppend(doc, op)
|
||||
case "prepend":
|
||||
return opPrepend(doc, op)
|
||||
case "replace":
|
||||
return opReplace(doc, op)
|
||||
case "update_text":
|
||||
return opUpdateText(doc, op)
|
||||
case "update_attrs":
|
||||
return opUpdateAttrs(doc, op)
|
||||
case "delete":
|
||||
return opDelete(doc, op)
|
||||
case "set_name":
|
||||
return opSetName(doc, op)
|
||||
case "set_icon":
|
||||
return opSetIcon(doc, op)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- per-op handlers ---------------- */
|
||||
|
||||
function opInsert(doc: Y.Doc, op: InsertOp): void {
|
||||
const found = findByUid(doc.getXmlFragment("content"), op.reference_uid)
|
||||
if (!found) {
|
||||
throw new Error(`reference_uid not found: ${op.reference_uid}`)
|
||||
}
|
||||
|
||||
const xml = pmBlockToY(op.block)
|
||||
const insertAt = op.position === "before" ? found.index : found.index + 1
|
||||
found.parent.insert(insertAt, [xml])
|
||||
}
|
||||
|
||||
function opAppend(doc: Y.Doc, op: AppendOp): void {
|
||||
const frag = doc.getXmlFragment("content")
|
||||
|
||||
// TipTap's trailingNode extension keeps an empty paragraph at the
|
||||
// end of the document as a click target. Inserting after it
|
||||
// produces a visible gap because the extension immediately
|
||||
// appends a fresh trailing paragraph, turning the old one into a
|
||||
// stray empty block. Insert before it instead so the trailing
|
||||
// paragraph stays last.
|
||||
let insertAt = frag.length
|
||||
if (insertAt > 0) {
|
||||
const last = frag.get(insertAt - 1)
|
||||
if (last instanceof Y.XmlElement && isEmptyParagraph(last)) {
|
||||
insertAt -= 1
|
||||
}
|
||||
}
|
||||
|
||||
frag.insert(insertAt, [pmBlockToY(op.block)])
|
||||
}
|
||||
|
||||
function opPrepend(doc: Y.Doc, op: PrependOp): void {
|
||||
const frag = doc.getXmlFragment("content")
|
||||
frag.insert(0, [pmBlockToY(op.block)])
|
||||
}
|
||||
|
||||
function opReplace(doc: Y.Doc, op: ReplaceOp): void {
|
||||
const found = findByUid(doc.getXmlFragment("content"), op.block_uid)
|
||||
if (!found) {
|
||||
throw new Error(`block_uid not found: ${op.block_uid}`)
|
||||
}
|
||||
|
||||
const xml = pmBlockToY(op.block)
|
||||
found.parent.delete(found.index, 1)
|
||||
found.parent.insert(found.index, [xml])
|
||||
}
|
||||
|
||||
function opUpdateText(doc: Y.Doc, op: UpdateTextOp): void {
|
||||
const found = findByUid(doc.getXmlFragment("content"), op.block_uid)
|
||||
if (!found) {
|
||||
throw new Error(`block_uid not found: ${op.block_uid}`)
|
||||
}
|
||||
|
||||
// Replace the block's children with a fresh Y.XmlText carrying
|
||||
// the new inline content. Block-level children (e.g. paragraphs
|
||||
// nested inside a list item) would also be wiped — update_text
|
||||
// is only meant for text-bearing leaf blocks (paragraph, heading,
|
||||
// codeBlock, mermaidBlock, …) so that's intended.
|
||||
found.element.delete(0, found.element.length)
|
||||
|
||||
const text = buildInlineText(op.content)
|
||||
if (text) {
|
||||
found.element.insert(0, [text])
|
||||
}
|
||||
}
|
||||
|
||||
function opUpdateAttrs(doc: Y.Doc, op: UpdateAttrsOp): void {
|
||||
const found = findByUid(doc.getXmlFragment("content"), op.block_uid)
|
||||
if (!found) {
|
||||
throw new Error(`block_uid not found: ${op.block_uid}`)
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(op.attrs)) {
|
||||
if (key === UID_ATTR) {
|
||||
// The uid is the block's identity; never let an
|
||||
// update_attrs op clobber it.
|
||||
continue
|
||||
}
|
||||
|
||||
// Y.XmlElement.setAttribute is typed as accepting strings
|
||||
// only, but the runtime stores arbitrary values, which is
|
||||
// what the editor schema relies on for nested metric
|
||||
// configuration. Cast through unknown to bypass the
|
||||
// declaration's stricter type.
|
||||
;(found.element as unknown as {
|
||||
setAttribute(key: string, value: unknown): void
|
||||
}).setAttribute(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
function opDelete(doc: Y.Doc, op: DeleteOp): void {
|
||||
const found = findByUid(doc.getXmlFragment("content"), op.block_uid)
|
||||
if (!found) {
|
||||
throw new Error(`block_uid not found: ${op.block_uid}`)
|
||||
}
|
||||
|
||||
found.parent.delete(found.index, 1)
|
||||
}
|
||||
|
||||
function opSetName(doc: Y.Doc, op: SetNameOp): void {
|
||||
const nameFrag = doc.getXmlFragment("name")
|
||||
nameFrag.delete(0, nameFrag.length)
|
||||
|
||||
const para = new Y.XmlElement("paragraph")
|
||||
para.setAttribute(UID_ATTR, nanoid())
|
||||
|
||||
if (op.name) {
|
||||
const text = new Y.XmlText()
|
||||
text.insert(0, op.name)
|
||||
para.insert(0, [text])
|
||||
}
|
||||
|
||||
nameFrag.insert(0, [para])
|
||||
}
|
||||
|
||||
function opSetIcon(doc: Y.Doc, op: SetIconOp): void {
|
||||
const iconText = doc.getText("icon")
|
||||
iconText.delete(0, iconText.length)
|
||||
if (op.icon) {
|
||||
iconText.insert(0, op.icon)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- helpers ---------------- */
|
||||
|
||||
/**
|
||||
* Locates the first descendant XmlElement whose uid attribute
|
||||
* matches the target, returning the element along with its
|
||||
* immediate parent fragment and index in that parent. Used by
|
||||
* operations that need to mutate a block in place or insert
|
||||
* adjacent to it.
|
||||
*/
|
||||
export function findByUid(
|
||||
fragment: Y.XmlFragment,
|
||||
uid: string,
|
||||
): { parent: Y.XmlFragment | Y.XmlElement; index: number; element: Y.XmlElement } | null {
|
||||
for (let i = 0; i < fragment.length; i++) {
|
||||
const child = fragment.get(i)
|
||||
if (!(child instanceof Y.XmlElement)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (child.getAttribute(UID_ATTR) === uid) {
|
||||
return { parent: fragment, index: i, element: child }
|
||||
}
|
||||
|
||||
const inside = findByUid(child, uid)
|
||||
if (inside) {
|
||||
return inside
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether el is a paragraph node with no visible inline
|
||||
* content. Used by opAppend to detect TipTap's trailing-paragraph
|
||||
* affordance so the new block is inserted before it instead of
|
||||
* after.
|
||||
*/
|
||||
function isEmptyParagraph(el: Y.XmlElement): boolean {
|
||||
if (el.nodeName !== "paragraph") {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let i = 0; i < el.length; i++) {
|
||||
const child = el.get(i)
|
||||
if (child instanceof Y.XmlText && child.length > 0) {
|
||||
return false
|
||||
}
|
||||
if (child instanceof Y.XmlElement) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a single ProseMirror block (as JSON) into a Y.XmlElement
|
||||
* detached from any document so the caller can insert it into a
|
||||
* live fragment. Uses the existing TiptapTransformer for the
|
||||
* schema-aware conversion and clones the result so we don't leak
|
||||
* the temporary Y.Doc.
|
||||
*/
|
||||
export function pmBlockToY(block: PMNode): Y.XmlElement {
|
||||
const tempDoc = transformer.toYdoc(
|
||||
{ type: "doc", content: [block] },
|
||||
"content",
|
||||
)
|
||||
|
||||
const frag = tempDoc.getXmlFragment("content")
|
||||
const first = frag.get(0)
|
||||
if (!(first instanceof Y.XmlElement)) {
|
||||
throw new Error("pmBlockToY: transformer produced no XmlElement")
|
||||
}
|
||||
|
||||
return cloneXmlElement(first)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Y.XmlText carrying the supplied ProseMirror inline
|
||||
* content (text nodes with optional marks). Returns null when the
|
||||
* content is empty so callers can skip the insert.
|
||||
*/
|
||||
function buildInlineText(content: PMInline[]): Y.XmlText | null {
|
||||
if (!content || content.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const text = new Y.XmlText()
|
||||
let cursor = 0
|
||||
|
||||
for (const node of content) {
|
||||
if (node.type !== "text" || !node.text) {
|
||||
continue
|
||||
}
|
||||
|
||||
const attrs = marksToAttrs(node.marks)
|
||||
text.insert(cursor, node.text, attrs)
|
||||
cursor += node.text.length
|
||||
}
|
||||
|
||||
if (cursor === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a ProseMirror mark array into the format-attribute
|
||||
* shape Y.XmlText expects: { [markType]: markAttrs | true }.
|
||||
*/
|
||||
function marksToAttrs(marks: PMMark[] | undefined): Record<string, unknown> | undefined {
|
||||
if (!marks || marks.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const mark of marks) {
|
||||
out[mark.type] = mark.attrs ?? true
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
194
server/auth-realtime/src/routes.ts
Normal file
194
server/auth-realtime/src/routes.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import * as Sentry from "@sentry/node"
|
||||
import { Hono } from "hono"
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import { cors } from "hono/cors"
|
||||
import { auth, totalOrganizationCount, MAX_ORGANIZATIONS } from "./auth.js"
|
||||
import * as Y from "yjs"
|
||||
import { hocuspocus, toAxiosHeaders } from "./hocuspocus.js"
|
||||
import { replaceYdocContent } from "./ydocument.js"
|
||||
import { applyOperations, type Operation } from "./operations.js"
|
||||
import axios from "axios"
|
||||
|
||||
const app = new Hono()
|
||||
const backendUrl = process.env.HEIMDALL_GUARD_BACKEND_URL
|
||||
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: (
|
||||
(process.env.HEIMDALL_GUARD_TRUSTED_ORIGINS || "") as string
|
||||
).split(","),
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowHeaders: ["Content-Type", "Authorization"],
|
||||
credentials: true,
|
||||
}),
|
||||
)
|
||||
|
||||
app.get("/", (c) => {
|
||||
return c.text("Hello Hono!")
|
||||
})
|
||||
|
||||
app.on(["POST", "GET"], "/auth/**", (c) => {
|
||||
return auth.handler(c.req.raw)
|
||||
})
|
||||
|
||||
app.get("/organizations/stats", async (c) => {
|
||||
const count = await totalOrganizationCount()
|
||||
return c.json({ availableSlots: MAX_ORGANIZATIONS - count })
|
||||
})
|
||||
|
||||
app.put("/documents/:documentId/merge", async (c) => {
|
||||
const documentId = c.req.param("documentId")
|
||||
|
||||
let fromBranchId: string
|
||||
let toBranchId: string
|
||||
|
||||
try {
|
||||
const body = await c.req.json()
|
||||
fromBranchId = body.fromBranchId
|
||||
toBranchId = body.toBranchId
|
||||
} catch {
|
||||
return c.json({ error: "invalid request body" }, 400)
|
||||
}
|
||||
|
||||
if (!fromBranchId || !toBranchId) {
|
||||
return c.json({ error: "fromBranchId and toBranchId are required" }, 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.put(
|
||||
`${backendUrl}/api/documents/${documentId}/merge`,
|
||||
{ fromBranchId, toBranchId },
|
||||
{
|
||||
headers: toAxiosHeaders(c.req.raw.headers),
|
||||
}
|
||||
)
|
||||
|
||||
if (response.status === 200) {
|
||||
const mergedData = {
|
||||
name: response.data.documentName,
|
||||
content: response.data.content,
|
||||
icon: response.data.icon,
|
||||
}
|
||||
|
||||
// update the in-memory target branch Y.Doc using direct cloning to
|
||||
// avoid Y.applyUpdate merge semantics that cause content
|
||||
// duplication when source and target docs have different
|
||||
// client origins.
|
||||
const toDocument = hocuspocus.documents.get(`${documentId}-${toBranchId}`)
|
||||
if (toDocument) {
|
||||
replaceYdocContent(toDocument, mergedData)
|
||||
|
||||
// persist rawContent immediately because MergeBranches
|
||||
// sets rawContent to nil in the database. Without
|
||||
// this, a server restart before onStoreDocument
|
||||
// would trigger onLoadDocument's first-time path,
|
||||
// creating a new Y.Doc with a different clientID
|
||||
// that CRDT-merges with reconnecting clients and
|
||||
// duplicates content.
|
||||
const rawContent = Buffer.from(
|
||||
Y.encodeStateAsUpdate(toDocument),
|
||||
).toString("base64")
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`${backendUrl}/api/x/documents/${documentId}/branch/${toBranchId}`,
|
||||
{
|
||||
name: mergedData.name,
|
||||
icon: mergedData.icon,
|
||||
content: mergedData.content,
|
||||
maintainers: [],
|
||||
rawContent,
|
||||
system: true,
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(response.data, response.status as ContentfulStatusCode)
|
||||
} catch (error: any) {
|
||||
Sentry.captureException(error)
|
||||
|
||||
if (error.response) {
|
||||
return c.json(
|
||||
error.response.data,
|
||||
error.response.status
|
||||
)
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{ error: "failed to merge" },
|
||||
500
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Server-to-server endpoint called by the Go assistant to apply
|
||||
// edit operations to a live Y.Doc. Each request opens (or reuses) a
|
||||
// direct connection to the document, runs the batch inside a single
|
||||
// Y.Doc transaction, and disconnects. Hocuspocus handles
|
||||
// broadcasting the resulting update to subscribed clients and
|
||||
// persisting via onStoreDocument.
|
||||
//
|
||||
// This route lives on the trusted internal network alongside the Go
|
||||
// `/api/x/...` endpoints; clients on the public internet have no
|
||||
// path to it.
|
||||
app.post(
|
||||
"/internal/documents/:documentId/branches/:branchId/operations",
|
||||
async (c) => {
|
||||
const documentId = c.req.param("documentId")
|
||||
const branchId = c.req.param("branchId")
|
||||
|
||||
let body: { operations?: Operation[] }
|
||||
try {
|
||||
body = await c.req.json()
|
||||
} catch {
|
||||
return c.json({ error: "invalid request body" }, 400)
|
||||
}
|
||||
|
||||
const ops = body.operations
|
||||
if (!Array.isArray(ops)) {
|
||||
return c.json({ error: "operations array is required" }, 400)
|
||||
}
|
||||
|
||||
// Hocuspocus addresses documents by `documentId-branchId`; the
|
||||
// Go caller resolves "default" to a concrete branchId before
|
||||
// sending so this path can be a direct lookup.
|
||||
const documentName = `${documentId}-${branchId}`
|
||||
|
||||
let connection
|
||||
try {
|
||||
connection = await hocuspocus.openDirectConnection(documentName)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
return c.json({ error: "failed to open document" }, 500)
|
||||
}
|
||||
|
||||
try {
|
||||
let result: { applied: number; errors: { index: number; message: string }[] } = {
|
||||
applied: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
await connection.transact((doc) => {
|
||||
result = applyOperations(doc, ops)
|
||||
})
|
||||
|
||||
return c.json(result, 200)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
return c.json({ error: "failed to apply operations" }, 500)
|
||||
} finally {
|
||||
try {
|
||||
await connection.disconnect()
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export default app
|
||||
42
server/auth-realtime/src/schema/callout.ts
Normal file
42
server/auth-realtime/src/schema/callout.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core"
|
||||
import Paragraph from "@tiptap/extension-paragraph"
|
||||
import { TaskList, BulletList, OrderedList } from "@tiptap/extension-list"
|
||||
|
||||
const allowedContent = [
|
||||
Paragraph.name,
|
||||
BulletList.name,
|
||||
OrderedList.name,
|
||||
TaskList.name,
|
||||
]
|
||||
|
||||
export const CalloutBlock = Node.create({
|
||||
name: "calloutBlock",
|
||||
content: `(${allowedContent.join(" | ")})+`,
|
||||
defining: true,
|
||||
selectable: false,
|
||||
isolating: true,
|
||||
group: "block",
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="callout-block"]` }]
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
icon: {
|
||||
default: "lucide:text",
|
||||
parseHTML: (element) => {
|
||||
return element.getAttribute("data-icon")
|
||||
},
|
||||
renderHTML: (attrs) => ({ "data-icon": attrs.icon }),
|
||||
},
|
||||
}
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "callout-block",
|
||||
}),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
104
server/auth-realtime/src/schema/code-block.ts
Normal file
104
server/auth-realtime/src/schema/code-block.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import CodeBlockLowlight, {
|
||||
type CodeBlockLowlightOptions,
|
||||
} from "@tiptap/extension-code-block-lowlight"
|
||||
import { mergeAttributes, Node } from "@tiptap/core"
|
||||
import { createLowlight } from "lowlight"
|
||||
import { CommentMark, AddedMark, DeletedMark } from "./marks.js"
|
||||
|
||||
const lowlight = createLowlight()
|
||||
|
||||
export const CodeBlockTitle = Node.create({
|
||||
name: "codeBlockTitle",
|
||||
content: "text*",
|
||||
marks: `${CommentMark.name} ${AddedMark.name} ${DeletedMark.name}`,
|
||||
group: "block",
|
||||
selectable: true,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="code-block-title"]` }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "code-block-title",
|
||||
}),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
export interface CodeBlockOptions extends CodeBlockLowlightOptions {
|
||||
type: "document" | "comment"
|
||||
}
|
||||
|
||||
export const CodeBlock = CodeBlockLowlight.extend<CodeBlockOptions>({
|
||||
name: CodeBlockLowlight.name,
|
||||
defining: true,
|
||||
isolating: false,
|
||||
selectable: true,
|
||||
marks: `${CommentMark.name} ${AddedMark.name} ${DeletedMark.name}`,
|
||||
whitespace: "pre",
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `pre[data-type="code-block"]`,
|
||||
preserveWhitespace: "full",
|
||||
},
|
||||
]
|
||||
},
|
||||
renderHTML({ node, HTMLAttributes }: { node: any; HTMLAttributes: Record<string, any> }) {
|
||||
return [
|
||||
"pre",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "code-block",
|
||||
}),
|
||||
[
|
||||
"code",
|
||||
{
|
||||
class: node.attrs.language
|
||||
? this.options.languageClassPrefix + node.attrs.language
|
||||
: null,
|
||||
},
|
||||
0,
|
||||
],
|
||||
]
|
||||
},
|
||||
addOptions(): CodeBlockOptions {
|
||||
const parent = this.parent ? this.parent() : null
|
||||
return {
|
||||
languageClassPrefix: "language-",
|
||||
exitOnArrowDown: true,
|
||||
tabSize: 2,
|
||||
HTMLAttributes: {},
|
||||
...parent,
|
||||
defaultLanguage: null,
|
||||
enableTabIndentation: true,
|
||||
exitOnTripleEnter: false,
|
||||
lowlight: lowlight,
|
||||
type: "document",
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const TitledCodeBlock = Node.create({
|
||||
name: "titledCodeBlock",
|
||||
group: "block",
|
||||
content: `${CodeBlockTitle.name} ${CodeBlock.name}`,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
selectable: false,
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="titled-code-block"]` }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "titled-code-block",
|
||||
}),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
52
server/auth-realtime/src/schema/figma.ts
Normal file
52
server/auth-realtime/src/schema/figma.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core"
|
||||
|
||||
export const FigmaBlock = Node.create({
|
||||
name: "figmaBlock",
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-src"),
|
||||
renderHTML: (attrs) => {
|
||||
if (!attrs.src) return {}
|
||||
return { "data-src": attrs.src }
|
||||
},
|
||||
},
|
||||
width: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const w = element.getAttribute("data-width")
|
||||
return w ? Number.parseInt(w, 10) : null
|
||||
},
|
||||
renderHTML: (attrs) => {
|
||||
if (!attrs.width) return {}
|
||||
return { "data-width": attrs.width }
|
||||
},
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const h = element.getAttribute("data-height")
|
||||
return h ? Number.parseInt(h, 10) : null
|
||||
},
|
||||
renderHTML: (attrs) => {
|
||||
if (!attrs.height) return {}
|
||||
return { "data-height": attrs.height }
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="figma-block"]` }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, { "data-type": "figma-block" }),
|
||||
]
|
||||
},
|
||||
})
|
||||
36
server/auth-realtime/src/schema/image.ts
Normal file
36
server/auth-realtime/src/schema/image.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core"
|
||||
|
||||
export const ImageBlock = Node.create({
|
||||
name: "imageBlock",
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
alt: {
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
default: null,
|
||||
},
|
||||
width: {
|
||||
default: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: `img[data-type="image-block"]` }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"img",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "image-block",
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
129
server/auth-realtime/src/schema/index.ts
Normal file
129
server/auth-realtime/src/schema/index.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import Document from "@tiptap/extension-document"
|
||||
import Paragraph from "@tiptap/extension-paragraph"
|
||||
import Text from "@tiptap/extension-text"
|
||||
import Heading from "@tiptap/extension-heading"
|
||||
import Blockquote from "@tiptap/extension-blockquote"
|
||||
import HorizontalRule from "@tiptap/extension-horizontal-rule"
|
||||
import Bold from "@tiptap/extension-bold"
|
||||
import Code from "@tiptap/extension-code"
|
||||
import Italic from "@tiptap/extension-italic"
|
||||
import Link from "@tiptap/extension-link"
|
||||
import Strike from "@tiptap/extension-strike"
|
||||
import Underline from "@tiptap/extension-underline"
|
||||
import UniqueID from "@tiptap/extension-unique-id"
|
||||
import {
|
||||
TaskItem,
|
||||
TaskList,
|
||||
BulletList,
|
||||
OrderedList,
|
||||
ListItem,
|
||||
ListKeymap,
|
||||
} from "@tiptap/extension-list"
|
||||
import type { Extensions } from "@tiptap/core"
|
||||
import { nanoid } from "nanoid"
|
||||
|
||||
// Custom blocks
|
||||
import { CalloutBlock } from "./callout.js"
|
||||
import { MermaidBlock } from "./mermaid.js"
|
||||
import { MetricBlock, MetricGrid } from "./metric.js"
|
||||
import { ImageBlock } from "./image.js"
|
||||
import { FigmaBlock } from "./figma.js"
|
||||
import { CodeBlock, CodeBlockTitle, TitledCodeBlock } from "./code-block.js"
|
||||
import {
|
||||
SplitDocumentation,
|
||||
SplitDocumentationLeftSide,
|
||||
SplitDocumentationRightSide,
|
||||
} from "./split-documentation.js"
|
||||
import {
|
||||
ParameterList,
|
||||
ParameterListHeader,
|
||||
ParameterListItem,
|
||||
ParameterListItemHeader,
|
||||
ParameterListItemHeaderType,
|
||||
ParameterListItemHeaderTitle,
|
||||
} from "./parameter-list.js"
|
||||
|
||||
// Custom marks
|
||||
import { AddedMark, DeletedMark, CommentMark } from "./marks.js"
|
||||
|
||||
const contentExtensionsWithIDs: Extensions = [
|
||||
Paragraph,
|
||||
Heading.configure({ levels: [1, 2, 3] }).extend({
|
||||
marks: `${CommentMark.name}`,
|
||||
}),
|
||||
HorizontalRule,
|
||||
Blockquote,
|
||||
ListItem,
|
||||
ListKeymap,
|
||||
BulletList,
|
||||
OrderedList,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
CalloutBlock,
|
||||
SplitDocumentation,
|
||||
SplitDocumentationLeftSide,
|
||||
SplitDocumentationRightSide,
|
||||
TitledCodeBlock,
|
||||
MetricBlock,
|
||||
MetricGrid,
|
||||
ImageBlock,
|
||||
FigmaBlock,
|
||||
CodeBlock,
|
||||
MermaidBlock,
|
||||
CodeBlockTitle,
|
||||
ParameterList,
|
||||
ParameterListHeader,
|
||||
ParameterListItem,
|
||||
ParameterListItemHeader,
|
||||
ParameterListItemHeaderType,
|
||||
ParameterListItemHeaderTitle,
|
||||
]
|
||||
|
||||
/**
|
||||
* Get all Tiptap extensions used in the bifrost editor
|
||||
* This matches the schema used by the frontend for document compatibility
|
||||
*/
|
||||
export function getEditorExtensions(): Extensions {
|
||||
return [
|
||||
...contentExtensionsWithIDs,
|
||||
|
||||
// Base extensions
|
||||
Document,
|
||||
Text,
|
||||
|
||||
// Text formatting marks
|
||||
Bold,
|
||||
Code,
|
||||
Italic,
|
||||
Strike,
|
||||
Underline,
|
||||
|
||||
// Link
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer",
|
||||
},
|
||||
}).extend({
|
||||
inclusive: false,
|
||||
}),
|
||||
|
||||
// Custom marks
|
||||
CommentMark,
|
||||
AddedMark,
|
||||
DeletedMark,
|
||||
UniqueID.configure({
|
||||
types: contentExtensionsWithIDs.map((v) => v.name),
|
||||
attributeName: "uid",
|
||||
generateID: () => nanoid(),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
// Export all blocks and marks for individual use
|
||||
export * from "./callout.js"
|
||||
export * from "./code-block.js"
|
||||
export * from "./split-documentation.js"
|
||||
export * from "./parameter-list.js"
|
||||
export * from "./marks.js"
|
||||
77
server/auth-realtime/src/schema/marks.ts
Normal file
77
server/auth-realtime/src/schema/marks.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { Mark } from "@tiptap/core"
|
||||
|
||||
export const AddedMark = Mark.create({
|
||||
name: "added",
|
||||
inclusive: false,
|
||||
priority: 100,
|
||||
excludes: "deleted",
|
||||
parseHTML() {
|
||||
return [{ tag: "span[data-added]" }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"span",
|
||||
{
|
||||
...HTMLAttributes,
|
||||
"data-added": "true",
|
||||
},
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
export const DeletedMark = Mark.create({
|
||||
name: "deleted",
|
||||
inclusive: false,
|
||||
priority: 100,
|
||||
excludes: "added",
|
||||
parseHTML() {
|
||||
return [{ tag: "span[data-deleted]" }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"span",
|
||||
{
|
||||
...HTMLAttributes,
|
||||
"data-deleted": "true",
|
||||
},
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
export interface CommentAttrs {
|
||||
commentId: string
|
||||
}
|
||||
|
||||
export const CommentMark = Mark.create({
|
||||
name: "comment",
|
||||
inclusive: false,
|
||||
priority: 1000,
|
||||
excludes: "",
|
||||
addAttributes() {
|
||||
return {
|
||||
commentId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-comment-id"),
|
||||
renderHTML: (attrs) => {
|
||||
return {
|
||||
"data-comment-id": attrs.commentId,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: "span[data-comment-id]" }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"span",
|
||||
{
|
||||
...HTMLAttributes,
|
||||
},
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
31
server/auth-realtime/src/schema/mermaid.ts
Normal file
31
server/auth-realtime/src/schema/mermaid.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core"
|
||||
import { CommentMark } from "./marks.js"
|
||||
|
||||
export const MermaidBlock = Node.create({
|
||||
name: "mermaidBlock",
|
||||
group: "block",
|
||||
content: "text*",
|
||||
marks: `${CommentMark.name}`,
|
||||
code: true,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
selectable: true,
|
||||
whitespace: "pre",
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `pre[data-type="mermaid-block"]`,
|
||||
preserveWhitespace: "full",
|
||||
},
|
||||
]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"pre",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "mermaid-block",
|
||||
}),
|
||||
["code", 0],
|
||||
]
|
||||
},
|
||||
})
|
||||
62
server/auth-realtime/src/schema/metric.ts
Normal file
62
server/auth-realtime/src/schema/metric.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core"
|
||||
|
||||
export const MetricGrid = Node.create({
|
||||
name: "metricGrid",
|
||||
group: "block",
|
||||
content: "metricBlock*", // allow zero blocks; empty grids are auto-deleted by metricGridAutoManager
|
||||
isolating: true,
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="metric-grid"]` }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "metric-grid",
|
||||
}),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
export const MetricBlock = Node.create({
|
||||
name: "metricBlock",
|
||||
group: "block",
|
||||
atom: true,
|
||||
defining: true,
|
||||
selectable: false,
|
||||
isolating: true,
|
||||
addAttributes() {
|
||||
return {
|
||||
config: {}, // NOTE: Old version.
|
||||
title: {},
|
||||
dataSourceId: {},
|
||||
visualizationType: {},
|
||||
queries: {},
|
||||
timeRange: {},
|
||||
refreshInterval: {},
|
||||
thresholds: {},
|
||||
baseThresholdColor: {},
|
||||
decimals: {},
|
||||
unitType: {},
|
||||
unitCustom: {},
|
||||
axisBoundsMin: {},
|
||||
axisBoundsMax: {},
|
||||
width: {},
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="metric-block"]` }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "metric-block",
|
||||
}),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
146
server/auth-realtime/src/schema/parameter-list.ts
Normal file
146
server/auth-realtime/src/schema/parameter-list.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core"
|
||||
import Paragraph from "@tiptap/extension-paragraph"
|
||||
import { CommentMark, AddedMark, DeletedMark } from "./marks.js"
|
||||
|
||||
export const ParameterListItemHeaderType = Node.create({
|
||||
name: "splitDocumentationParameterListItemHeaderType",
|
||||
group: "block",
|
||||
content: `text*`,
|
||||
marks: `${CommentMark.name} ${AddedMark.name} ${DeletedMark.name}`,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
selectable: false,
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="split-documentation-parameter-list-item-header-type"]`,
|
||||
},
|
||||
]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "split-documentation-parameter-list-item-header-type",
|
||||
}),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
export const ParameterListItemHeaderTitle = Node.create({
|
||||
name: "splitDocumentationParameterListItemHeaderTitle",
|
||||
group: "block",
|
||||
content: `text*`,
|
||||
marks: `${CommentMark.name} ${AddedMark.name} ${DeletedMark.name}`,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
selectable: false,
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="split-documentation-parameter-list-item-header-title"]`,
|
||||
},
|
||||
]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "split-documentation-parameter-list-item-header-title",
|
||||
}),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
export const ParameterListItemHeader = Node.create({
|
||||
name: "splitDocumentationParameterListItemHeader",
|
||||
group: "block",
|
||||
content: `${ParameterListItemHeaderTitle.name} ${ParameterListItemHeaderType.name}`,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
selectable: false,
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="split-documentation-parameter-list-item-header"]`,
|
||||
},
|
||||
]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "split-documentation-parameter-list-item-header",
|
||||
}),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
export const ParameterListItem = Node.create({
|
||||
name: "splitDocumentationParameterListItem",
|
||||
group: "block",
|
||||
content: `${ParameterListItemHeader.name} ${Paragraph.name}`,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
selectable: false,
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="split-documentation-parameter-list-item"]` }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "split-documentation-parameter-list-item",
|
||||
}),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
export const ParameterListHeader = Node.create({
|
||||
name: "splitDocumentationParameterListHeader",
|
||||
content: `text*`,
|
||||
group: "block",
|
||||
marks: `${CommentMark.name} ${AddedMark.name} ${DeletedMark.name}`,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
selectable: false,
|
||||
parseHTML() {
|
||||
return [
|
||||
{ tag: `div[data-type="split-documentation-parameter-list-header"]` },
|
||||
]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "split-documentation-parameter-list-header",
|
||||
}),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
export const ParameterList = Node.create({
|
||||
name: "splitDocumentationParameterList",
|
||||
group: "block",
|
||||
content: `${ParameterListHeader.name} ${ParameterListItem.name}+`,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
selectable: false,
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="split-documentation-parameter-list"]` }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "split-documentation-parameter-list",
|
||||
}),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
98
server/auth-realtime/src/schema/split-documentation.ts
Normal file
98
server/auth-realtime/src/schema/split-documentation.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core"
|
||||
import Paragraph from "@tiptap/extension-paragraph"
|
||||
import Heading from "@tiptap/extension-heading"
|
||||
import { TaskList, BulletList, OrderedList } from "@tiptap/extension-list"
|
||||
import { ParameterList } from "./parameter-list.js"
|
||||
import { CalloutBlock } from "./callout.js"
|
||||
import { TitledCodeBlock } from "./code-block.js"
|
||||
import { MetricBlock } from "./metric.js"
|
||||
|
||||
export const allowedLeftSideContent = [
|
||||
Paragraph.name,
|
||||
BulletList.name,
|
||||
OrderedList.name,
|
||||
TaskList.name,
|
||||
CalloutBlock.name,
|
||||
]
|
||||
|
||||
export const extraLeftSideContent = [ParameterList.name]
|
||||
|
||||
export const SplitDocumentationLeftSide = Node.create({
|
||||
name: "splitDocumentationLeftSide",
|
||||
group: "block",
|
||||
isolating: false,
|
||||
defining: true,
|
||||
selectable: false,
|
||||
content: `${Heading.name} (${allowedLeftSideContent.join(" | ")})+ (${extraLeftSideContent.join(" | ")})*`,
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="split-documentation-left-side"]` }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "split-documentation-left-side",
|
||||
}),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
export const SplitDocumentationRightSide = Node.create({
|
||||
name: "splitDocumentationRightSide",
|
||||
group: "block",
|
||||
isolating: false,
|
||||
defining: true,
|
||||
selectable: false,
|
||||
content: `(${TitledCodeBlock.name} | ${MetricBlock.name})+`,
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="split-documentation-right-side"]` }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "split-documentation-right-side",
|
||||
}),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
export const SplitDocumentation = Node.create({
|
||||
name: "splitDocumentation",
|
||||
group: "block",
|
||||
content: `${SplitDocumentationLeftSide.name} ${SplitDocumentationRightSide.name}`,
|
||||
isolating: false,
|
||||
defining: true,
|
||||
selectable: false,
|
||||
addAttributes() {
|
||||
return {
|
||||
inversed: {
|
||||
default: false,
|
||||
parseHTML: (element) => element.getAttribute("data-inversed") === "true",
|
||||
renderHTML: (attrs) => {
|
||||
if (!attrs.inversed) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return { "data-inversed": "true" }
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="split-documentation"]`,
|
||||
},
|
||||
]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, { "data-type": "split-documentation" }),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
103
server/auth-realtime/src/ydocument.ts
Normal file
103
server/auth-realtime/src/ydocument.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import * as Y from "yjs"
|
||||
import { TiptapTransformer } from "@hocuspocus/transformer"
|
||||
import { getEditorExtensions } from "./schema/index.js"
|
||||
|
||||
export interface DocumentData {
|
||||
name: string
|
||||
content: any
|
||||
icon: string
|
||||
}
|
||||
|
||||
// Create a TiptapTransformer instance with the bifrost schema
|
||||
const extensions = getEditorExtensions()
|
||||
const transformer = TiptapTransformer.extensions(extensions)
|
||||
|
||||
/**
|
||||
* Deep-clones a Y.XmlElement, preserving all attribute types (including arrays
|
||||
* and objects). Y.XmlElement.clone() only copies string attributes, silently
|
||||
* dropping complex attributes like the `queries` array on metricBlock.
|
||||
*/
|
||||
export function cloneXmlElement(source: Y.XmlElement): Y.XmlElement {
|
||||
const el = new Y.XmlElement(source.nodeName)
|
||||
// Y.XmlElement.setAttribute is declared as accepting `string`,
|
||||
// but the runtime stores arbitrary values which we rely on for
|
||||
// complex attributes (e.g. the queries array on metricBlock).
|
||||
// Cast through unknown to bypass the type declaration without
|
||||
// dropping non-string values.
|
||||
const setAttr = (
|
||||
el as unknown as { setAttribute(key: string, value: unknown): void }
|
||||
).setAttribute.bind(el)
|
||||
|
||||
const attrs = source.getAttributes()
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (value === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
setAttr(key, value)
|
||||
}
|
||||
const children = source.toArray()
|
||||
if (children.length > 0) {
|
||||
el.insert(0, children.map(child => {
|
||||
if (child instanceof Y.XmlElement) {
|
||||
return cloneXmlElement(child)
|
||||
}
|
||||
const newText = new Y.XmlText()
|
||||
newText.applyDelta((child as Y.XmlText).toDelta())
|
||||
return newText
|
||||
}))
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
function cloneXmlFragment(source: Y.XmlFragment, target: Y.XmlFragment): void {
|
||||
target.delete(0, target.length)
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const item = source.get(i)
|
||||
if (item instanceof Y.XmlElement) {
|
||||
target.insert(i, [cloneXmlElement(item)])
|
||||
} else if (item instanceof Y.XmlText) {
|
||||
const newText = new Y.XmlText()
|
||||
newText.applyDelta(item.toDelta())
|
||||
target.insert(i, [newText])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toNameContent(name: string) {
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: name }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces document data in a Y.Doc, fully overwriting existing content.
|
||||
* Uses direct cloning to avoid applyUpdate merge semantics that can cause duplication.
|
||||
*/
|
||||
export function replaceYdocContent(ydoc: Y.Doc, data: DocumentData): void {
|
||||
ydoc.transact(() => {
|
||||
const nameDoc = transformer.toYdoc(toNameContent(data.name), "name")
|
||||
cloneXmlFragment(
|
||||
nameDoc.getXmlFragment("name"),
|
||||
ydoc.getXmlFragment("name"),
|
||||
)
|
||||
|
||||
const contentDoc = transformer.toYdoc(data.content, "content")
|
||||
cloneXmlFragment(
|
||||
contentDoc.getXmlFragment("content"),
|
||||
ydoc.getXmlFragment("content"),
|
||||
)
|
||||
|
||||
const iconText = ydoc.getText("icon")
|
||||
iconText.delete(0, iconText.length)
|
||||
iconText.insert(0, data.icon)
|
||||
})
|
||||
}
|
||||
|
||||
export { transformer }
|
||||
25
server/auth-realtime/tsconfig.json
Normal file
25
server/auth-realtime/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"types": ["node"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"outDir": "./dist",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"strict": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"removeComments": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitAny": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
87
server/core/.goreleaser.yml
Normal file
87
server/core/.goreleaser.yml
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
version: 2
|
||||
project_name: heimdall
|
||||
dist: bin
|
||||
snapshot:
|
||||
version_template: dev
|
||||
builds:
|
||||
- id: heimdall-gate
|
||||
main: ./cmd/gate
|
||||
binary: "{{ .ProjectName }}-gate"
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
no_unique_dist_dir: true
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
tags:
|
||||
- "{{if not .IsSnapshot}} prod {{end}}"
|
||||
flags:
|
||||
- "-trimpath"
|
||||
ldflags:
|
||||
- "-s -w"
|
||||
- "-X github.com/oxynote/heimdall/internal/buildinfo._name={{ .ProjectName }}_gate"
|
||||
- "-X github.com/oxynote/heimdall/internal/buildinfo._version={{ .Version }}"
|
||||
- "-X github.com/oxynote/heimdall/internal/buildinfo._commit={{ .ShortCommit }}"
|
||||
- "-X github.com/oxynote/heimdall/internal/buildinfo._timestampString={{ .CommitDate }}"
|
||||
- id: heimdall-connector
|
||||
main: ./cmd/connector
|
||||
binary: "{{ .ProjectName }}-connector"
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
no_unique_dist_dir: true
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
tags:
|
||||
- "{{if not .IsSnapshot}} prod {{end}}"
|
||||
flags:
|
||||
- "-trimpath"
|
||||
ldflags:
|
||||
- "-s -w"
|
||||
- "-X github.com/oxynote/heimdall/internal/buildinfo._name={{ .ProjectName }}_connector"
|
||||
- "-X github.com/oxynote/heimdall/internal/buildinfo._version={{ .Version }}"
|
||||
- "-X github.com/oxynote/heimdall/internal/buildinfo._commit={{ .ShortCommit }}"
|
||||
- "-X github.com/oxynote/heimdall/internal/buildinfo._timestampString={{ .CommitDate }}"
|
||||
dockers:
|
||||
- ids:
|
||||
- heimdall-gate
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
image_templates:
|
||||
- "ghcr.io/oxynote/heimdall-gate:{{ .Version }}"
|
||||
- "ghcr.io/oxynote/heimdall-gate:latest"
|
||||
dockerfile: ./docker/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--platform=linux/amd64"
|
||||
- "--build-arg=BINARY=heimdall-gate"
|
||||
- ids:
|
||||
- heimdall-connector
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
image_templates:
|
||||
- "ghcr.io/oxynote/heimdall-connector:{{ .Version }}"
|
||||
- "ghcr.io/oxynote/heimdall-connector:latest"
|
||||
dockerfile: ./docker/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--platform=linux/amd64"
|
||||
- "--build-arg=BINARY=heimdall-connector"
|
||||
checksum:
|
||||
disable: true
|
||||
archives:
|
||||
- formats: ["binary"] # disable archives
|
||||
release:
|
||||
disable: true # disable release uploading to github/gitlab
|
||||
changelog:
|
||||
use: github
|
||||
filters:
|
||||
exclude:
|
||||
- "^workflow(s?):"
|
||||
- "^github:"
|
||||
announce:
|
||||
skip: true
|
||||
3
server/core/Makefile
Normal file
3
server/core/Makefile
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Build the Docker image
|
||||
build:
|
||||
goreleaser release --snapshot --clean
|
||||
140
server/core/cmd/connector/main.go
Normal file
140
server/core/cmd/connector/main.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/oxynote/heimdall/internal/buildinfo"
|
||||
"github.com/oxynote/heimdall/internal/connector"
|
||||
"github.com/oxynote/purse/util/ioutil"
|
||||
"github.com/oxynote/purse/util/logutil"
|
||||
"github.com/oxynote/purse/util/metricutil"
|
||||
"github.com/oxynote/purse/util/sentryutil"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var closers []io.Closer
|
||||
|
||||
log, flushLogs, ok := newLogger()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
closers = append(closers, ioutil.CloserFunc(func() error {
|
||||
flushLogs()
|
||||
return nil
|
||||
}))
|
||||
|
||||
metrics, cleanup := newMetrics(log)
|
||||
defer cleanup()
|
||||
|
||||
termCtx, termCancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer termCancel()
|
||||
|
||||
srv, err := connector.NewServer(
|
||||
termCtx,
|
||||
log,
|
||||
connector.Options{
|
||||
Port: 8080,
|
||||
},
|
||||
metrics,
|
||||
)
|
||||
if err != nil {
|
||||
err = ioutil.AppendCloseErr(
|
||||
ioutil.MultiCloser(true, closers...),
|
||||
err,
|
||||
)
|
||||
|
||||
log.With("error", err).
|
||||
Error("cannot create a server")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
closers = append([]io.Closer{srv}, closers...)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Go(srv.Listen)
|
||||
|
||||
<-termCtx.Done()
|
||||
|
||||
cerr := ioutil.MultiCloser(true, closers...).Close()
|
||||
if cerr != nil {
|
||||
log.With("error", cerr).
|
||||
Error("error closing resources")
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// newLogger initializes a new logger with Sentry support.
|
||||
func newLogger() (*slog.Logger, func(), bool) {
|
||||
var level slog.Level
|
||||
|
||||
buildLevel := buildinfo.Getenv("LOG_LEVEL")
|
||||
if buildLevel != "" {
|
||||
err := level.UnmarshalText([]byte(buildLevel))
|
||||
if err != nil {
|
||||
slog.Warn(`invalid log level, defaulting to "INFO"`)
|
||||
}
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
})).With("version", buildinfo.Full().Version)
|
||||
|
||||
dsn := buildinfo.Getenv("SENTRY_DSN")
|
||||
if dsn == "" && !buildinfo.IsDevEnv() {
|
||||
log.Error("invalid sentry dsn")
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
fn, err := sentryutil.Setup(sentryutil.Config{
|
||||
DSN: dsn,
|
||||
Release: buildinfo.Full().PlainVersion(),
|
||||
Environment: buildinfo.Full().Env,
|
||||
ReleaseName: buildinfo.Full().VersionName(),
|
||||
ReleaseCommit: buildinfo.Full().Commit,
|
||||
ReleaseTimestamp: buildinfo.Full().FormattedTimestamp(),
|
||||
Deduplication: sentryutil.DeduplicationConfig{
|
||||
Interval: 10 * time.Minute,
|
||||
Capacity: 100,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("cannot set up sentry")
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
return log, fn, true
|
||||
}
|
||||
|
||||
// newMetrics initializes and starts the metrics collection.
|
||||
func newMetrics(log *slog.Logger) (metricutil.Factory, func()) {
|
||||
metricsStopCh := make(chan struct{})
|
||||
|
||||
metrics := metricutil.NewFactory(buildinfo.Full().Name, prometheus.NewRegistry())
|
||||
|
||||
metricsCtx, metricsCancel := context.WithCancel(context.Background())
|
||||
|
||||
go func() {
|
||||
defer logutil.Recover(log, nil)
|
||||
|
||||
metrics.CollectRuntimeMetrics(metricsCtx, time.Second)
|
||||
|
||||
close(metricsStopCh)
|
||||
}()
|
||||
|
||||
return metrics, func() {
|
||||
metricsCancel()
|
||||
<-metricsStopCh
|
||||
}
|
||||
}
|
||||
363
server/core/cmd/gate/main.go
Normal file
363
server/core/cmd/gate/main.go
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/oxynote/heimdall/internal/apps/githubapp"
|
||||
"github.com/oxynote/heimdall/internal/apps/slackapp"
|
||||
"github.com/oxynote/heimdall/internal/apps/webchanges"
|
||||
"github.com/oxynote/heimdall/internal/assistant"
|
||||
"github.com/oxynote/heimdall/internal/buildinfo"
|
||||
"github.com/oxynote/heimdall/internal/datasource/connector"
|
||||
"github.com/oxynote/heimdall/internal/db"
|
||||
hookMan "github.com/oxynote/heimdall/internal/document/hook/manager"
|
||||
"github.com/oxynote/heimdall/internal/document/liveedit"
|
||||
"github.com/oxynote/heimdall/internal/document/searchgw"
|
||||
searchMan "github.com/oxynote/heimdall/internal/document/searchgw/manager"
|
||||
"github.com/oxynote/heimdall/internal/email"
|
||||
"github.com/oxynote/heimdall/internal/notification"
|
||||
"github.com/oxynote/heimdall/internal/notification/interpreter"
|
||||
"github.com/oxynote/heimdall/internal/server"
|
||||
"github.com/oxynote/heimdall/internal/server/auth"
|
||||
"github.com/oxynote/heimdall/internal/storage"
|
||||
"github.com/oxynote/purse/util/ioutil"
|
||||
"github.com/oxynote/purse/util/logutil"
|
||||
"github.com/oxynote/purse/util/metricutil"
|
||||
"github.com/oxynote/purse/util/redisutil"
|
||||
"github.com/oxynote/purse/util/sentryutil"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var closers []io.Closer
|
||||
|
||||
log, flushLogs, ok := newLogger()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
closers = append(closers, ioutil.CloserFunc(func() error {
|
||||
flushLogs()
|
||||
return nil
|
||||
}))
|
||||
|
||||
metrics, cleanup := newMetrics(log)
|
||||
defer cleanup()
|
||||
|
||||
dbc, err := db.New(log, metrics, db.Options{
|
||||
DSN: buildinfo.Getenv("DB_DSN"),
|
||||
MaxNotifications: 500,
|
||||
DataSourceCredentialsSigningSecret: buildinfo.Getenv("DB_DATA_SOURCE_CREDENTIALS_SIGNING_SECRET"),
|
||||
})
|
||||
if err != nil {
|
||||
err = ioutil.AppendCloseErr(
|
||||
ioutil.MultiCloser(true, closers...),
|
||||
err,
|
||||
)
|
||||
|
||||
log.With("error", err).
|
||||
Error("cannot create a database connection")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
closers = append([]io.Closer{dbc}, closers...)
|
||||
|
||||
rdb, err := redisutil.NewPool(
|
||||
buildinfo.Getenv("VALKEY_NETWORK"),
|
||||
buildinfo.Getenv("VALKEY_ADDRESS"),
|
||||
)
|
||||
if err != nil {
|
||||
err = ioutil.AppendCloseErr(
|
||||
ioutil.MultiCloser(true, closers...),
|
||||
fmt.Errorf("cannot create a redis pool: %w", err),
|
||||
)
|
||||
|
||||
log.With("error", err).
|
||||
Error("cannot create a redis pool")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
closers = append([]io.Closer{rdb}, closers...)
|
||||
|
||||
githubAppID, err := strconv.ParseInt(buildinfo.Getenv("GITHUB_APP_ID"), 10, 64)
|
||||
if err != nil {
|
||||
err = ioutil.AppendCloseErr(
|
||||
ioutil.MultiCloser(true, closers...),
|
||||
fmt.Errorf("invalid GITHUB_APP_ID: %w", err),
|
||||
)
|
||||
|
||||
log.With("error", err).
|
||||
Error("cannot parse GITHUB_APP_ID")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
githubMan, err := githubapp.NewManager(dbc, githubapp.Options{
|
||||
AppID: githubAppID,
|
||||
AppSlug: buildinfo.Getenv("GITHUB_APP_SLUG"),
|
||||
SignatureSecret: buildinfo.Getenv("GITHUB_SIGNATURE_SECRET"),
|
||||
PrivateKeyPath: buildinfo.Getenv("GITHUB_PRIVATE_KEY_PATH"),
|
||||
InstallationSigningSecret: buildinfo.Getenv("GITHUB_INSTALLATION_SIGNING_SECRET"),
|
||||
})
|
||||
if err != nil {
|
||||
err = ioutil.AppendCloseErr(
|
||||
ioutil.MultiCloser(true, closers...),
|
||||
fmt.Errorf("cannot create github app manager: %w", err),
|
||||
)
|
||||
|
||||
log.With("error", err).
|
||||
Error("cannot create github app manager")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notifMan := notification.NewManager(log, dbc)
|
||||
closers = append([]io.Closer{notifMan}, closers...)
|
||||
|
||||
slackMan, err := slackapp.NewManager(
|
||||
log,
|
||||
dbc,
|
||||
interpreter.NewInterpreter(dbc, buildinfo.Getenv("BASE_APP_URL")),
|
||||
notifMan,
|
||||
slackapp.Options{
|
||||
RedirectURL: buildinfo.Getenv("SLACK_REDIRECT_URL"),
|
||||
ClientID: buildinfo.Getenv("SLACK_CLIENT_ID"),
|
||||
ClientSecret: buildinfo.Getenv("SLACK_CLIENT_SECRET"),
|
||||
SignatureSecret: buildinfo.Getenv("SLACK_SIGNATURE_SECRET"),
|
||||
InstallationSigningSecret: buildinfo.Getenv("SLACK_INSTALLATION_SIGNING_SECRET"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
err = ioutil.AppendCloseErr(
|
||||
ioutil.MultiCloser(true, closers...),
|
||||
fmt.Errorf("cannot create slack app manager: %w", err),
|
||||
)
|
||||
|
||||
log.With("error", err).
|
||||
Error("cannot create slack app manager")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
closers = append([]io.Closer{slackMan}, closers...)
|
||||
|
||||
webchangesClient := webchanges.NewClient(
|
||||
buildinfo.Getenv("CHANGEDETECTION_API_URL"),
|
||||
buildinfo.Getenv("CHANGEDETECTION_API_KEY"),
|
||||
)
|
||||
|
||||
termCtx, termCancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer termCancel()
|
||||
|
||||
searchClient, err := searchgw.NewClient(
|
||||
termCtx,
|
||||
meilisearch.New(
|
||||
buildinfo.Getenv("MEILISEARCH_DSN"),
|
||||
meilisearch.WithAPIKey(buildinfo.Getenv("MEILISEARCH_MASTER_KEY")),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
err = ioutil.AppendCloseErr(
|
||||
ioutil.MultiCloser(true, closers...),
|
||||
fmt.Errorf("cannot create search gateway client: %w", err),
|
||||
)
|
||||
|
||||
log.With("error", err).
|
||||
Error("cannot create search gateway client")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
storageClient, err := storage.NewClient(
|
||||
termCtx,
|
||||
storage.Options{
|
||||
URL: buildinfo.Getenv("STORAGE_URL"),
|
||||
AccessKey: buildinfo.Getenv("STORAGE_ACCESS_KEY"),
|
||||
SecretKey: buildinfo.Getenv("STORAGE_SECRET_KEY"),
|
||||
Bucket: buildinfo.Getenv("STORAGE_BUCKET"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
err = ioutil.AppendCloseErr(
|
||||
ioutil.MultiCloser(true, closers...),
|
||||
fmt.Errorf("cannot create storage client: %w", err),
|
||||
)
|
||||
|
||||
log.With("error", err).
|
||||
Error("cannot create storage client")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var origins []string
|
||||
|
||||
if biOrigins := strings.Split(buildinfo.Getenv("ORIGINS"), ","); len(biOrigins) > 0 {
|
||||
origins = biOrigins
|
||||
}
|
||||
|
||||
emailSender := email.NewSender(
|
||||
log,
|
||||
buildinfo.Getenv("EMAIL_MAILGUN_DOMAIN"),
|
||||
buildinfo.Getenv("EMAIL_MAILGUN_API_KEY"),
|
||||
)
|
||||
|
||||
connectorClient := connector.NewClient(
|
||||
buildinfo.Getenv("CONNECTOR_URL"),
|
||||
http.DefaultClient,
|
||||
)
|
||||
|
||||
liveEditClient := liveedit.NewClient(
|
||||
http.DefaultClient,
|
||||
buildinfo.Getenv("NODE_URL"),
|
||||
)
|
||||
|
||||
assistantMan := assistant.NewManager(
|
||||
log,
|
||||
dbc,
|
||||
rdb,
|
||||
buildinfo.Getenv("ANTHROPIC_API_KEY"),
|
||||
metrics,
|
||||
liveEditClient,
|
||||
searchClient,
|
||||
)
|
||||
|
||||
srv, err := server.NewServer(
|
||||
termCtx,
|
||||
log,
|
||||
server.Options{
|
||||
PublicURL: buildinfo.Getenv("SERVER_PUBLIC_URL"),
|
||||
DemoPrometheusURL: buildinfo.Getenv("SERVER_DEMO_PROMETHEUS_URL"),
|
||||
Port: 8080,
|
||||
Origins: origins,
|
||||
Auth: auth.Options{
|
||||
BetterAuthURL: buildinfo.Getenv("SERVER_AUTH_BETTER_AUTH_URL"),
|
||||
},
|
||||
},
|
||||
dbc,
|
||||
metrics,
|
||||
storageClient,
|
||||
assistantMan,
|
||||
githubMan,
|
||||
slackMan,
|
||||
webchangesClient,
|
||||
searchClient,
|
||||
notifMan,
|
||||
emailSender,
|
||||
connectorClient,
|
||||
http.DefaultClient,
|
||||
)
|
||||
if err != nil {
|
||||
err = ioutil.AppendCloseErr(
|
||||
ioutil.MultiCloser(true, closers...),
|
||||
err,
|
||||
)
|
||||
|
||||
log.With("error", err).
|
||||
Error("cannot create a server")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hooksMan := hookMan.NewManager(log, dbc, githubMan, webchangesClient, notifMan)
|
||||
searchMan := searchMan.NewManager(log, dbc, searchClient)
|
||||
|
||||
closers = append([]io.Closer{srv}, closers...)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Go(srv.Listen)
|
||||
wg.Go(func() {
|
||||
hooksMan.Start(termCtx)
|
||||
})
|
||||
wg.Go(func() {
|
||||
searchMan.Start(termCtx)
|
||||
})
|
||||
|
||||
<-termCtx.Done()
|
||||
|
||||
cerr := ioutil.MultiCloser(true, closers...).Close()
|
||||
if cerr != nil {
|
||||
log.With("error", cerr).
|
||||
Error("error closing resources")
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// newLogger initializes a new logger with Sentry support.
|
||||
func newLogger() (*slog.Logger, func(), bool) {
|
||||
var level slog.Level
|
||||
|
||||
buildLevel := buildinfo.Getenv("LOG_LEVEL")
|
||||
if buildLevel != "" {
|
||||
err := level.UnmarshalText([]byte(buildLevel))
|
||||
if err != nil {
|
||||
slog.Warn(`invalid log level, defaulting to "INFO"`)
|
||||
}
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
})).With("version", buildinfo.Full().Version)
|
||||
|
||||
dsn := buildinfo.Getenv("SENTRY_DSN")
|
||||
if dsn == "" && !buildinfo.IsDevEnv() {
|
||||
log.Error("invalid sentry dsn")
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
fn, err := sentryutil.Setup(sentryutil.Config{
|
||||
DSN: dsn,
|
||||
Release: buildinfo.Full().PlainVersion(),
|
||||
Environment: buildinfo.Full().Env,
|
||||
ReleaseName: buildinfo.Full().VersionName(),
|
||||
ReleaseCommit: buildinfo.Full().Commit,
|
||||
ReleaseTimestamp: buildinfo.Full().FormattedTimestamp(),
|
||||
Deduplication: sentryutil.DeduplicationConfig{
|
||||
Interval: 10 * time.Minute,
|
||||
Capacity: 100,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("cannot set up sentry")
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
return log, fn, true
|
||||
}
|
||||
|
||||
// newMetrics initializes and starts the metrics collection.
|
||||
func newMetrics(log *slog.Logger) (metricutil.Factory, func()) {
|
||||
metricsStopCh := make(chan struct{})
|
||||
|
||||
metrics := metricutil.NewFactory(buildinfo.Full().Name, prometheus.NewRegistry())
|
||||
|
||||
metricsCtx, metricsCancel := context.WithCancel(context.Background())
|
||||
|
||||
go func() {
|
||||
defer logutil.Recover(log, nil)
|
||||
|
||||
metrics.CollectRuntimeMetrics(metricsCtx, time.Second)
|
||||
|
||||
close(metricsStopCh)
|
||||
}()
|
||||
|
||||
return metrics, func() {
|
||||
metricsCancel()
|
||||
<-metricsStopCh
|
||||
}
|
||||
}
|
||||
5
server/core/docker/Dockerfile
Normal file
5
server/core/docker/Dockerfile
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
FROM gcr.io/distroless/static-debian10:nonroot
|
||||
ARG BINARY
|
||||
COPY ${BINARY} /app
|
||||
ENTRYPOINT ["/app"]
|
||||
EXPOSE 8080
|
||||
140
server/core/go.mod
Normal file
140
server/core/go.mod
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
module github.com/oxynote/heimdall
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/anthropics/anthropic-sdk-go v1.45.0
|
||||
github.com/blang/semver/v4 v4.0.0
|
||||
github.com/bradleyfalzon/ghinstallation/v2 v2.18.0
|
||||
github.com/cenkalti/backoff/v4 v4.3.0
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/dchest/uniuri v1.2.0
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/gomodule/redigo v1.9.3
|
||||
github.com/google/go-containerregistry v0.21.6
|
||||
github.com/google/go-github/v72 v72.0.0
|
||||
github.com/gosimple/slug v1.15.0
|
||||
github.com/guregu/null/v5 v5.0.0
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/jellydator/xync v0.0.0-20240601154136-0f038e166df4
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0
|
||||
github.com/mailgun/mailgun-go/v5 v5.15.0
|
||||
github.com/meilisearch/meilisearch-go v0.36.2
|
||||
github.com/minio/minio-go/v7 v7.1.0
|
||||
github.com/orlangure/gnomock v0.32.0
|
||||
github.com/oxynote/purse v0.0.0-20260525085705-c8f57bdcdeee
|
||||
github.com/oxynote/wetsocks v0.0.0-20260211182956-901c9ce8a66d
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/common v0.67.5
|
||||
github.com/qustavo/sqlhooks/v2 v2.1.0
|
||||
github.com/rs/xid v1.6.0
|
||||
github.com/rubenv/sql-migrate v1.8.1
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/slack-go/slack v0.23.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.19.0
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/sync v0.20.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/buger/jsonparser v1.2.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/cli v29.5.2+incompatible // indirect
|
||||
github.com/docker/docker v28.5.2+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.7 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/getsentry/sentry-go v0.46.2 // indirect
|
||||
github.com/go-gorp/gorp/v3 v3.1.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/go-github/v84 v84.0.0 // indirect
|
||||
github.com/google/go-querystring v1.2.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/schema v1.4.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/gosimple/unidecode v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jarcoal/httpmock v1.4.0 // indirect
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0 // indirect
|
||||
github.com/jellydator/validation v1.2.0 // indirect
|
||||
github.com/jpillora/backoff v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mailru/easyjson v0.9.2 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect
|
||||
github.com/oapi-codegen/runtime v1.4.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
349
server/core/go.sum
Normal file
349
server/core/go.sum
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
||||
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.45.0 h1:rWnpyBpm9OAm97jyH5bi6W4SRCwJeNY/RyhaJ7CHSUI=
|
||||
github.com/anthropics/anthropic-sdk-go v1.45.0/go.mod h1:bx5vWuHFuGPkELH8Z4KUiNSohFnUwScdpTyr+50myPo=
|
||||
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
|
||||
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
|
||||
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
|
||||
github.com/bradleyfalzon/ghinstallation/v2 v2.18.0 h1:WPqnN6NS9XvYlOgZQAIseN7Z1uAiE+UxgDKlW7FvFuU=
|
||||
github.com/bradleyfalzon/ghinstallation/v2 v2.18.0/go.mod h1:gpoSwwWc4biE49F7n+roCcpkEkZ1Qr9soZ2ESvMiouU=
|
||||
github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
|
||||
github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g=
|
||||
github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/docker/cli v29.5.2+incompatible h1:ubykJ1Y8LmNRGJ2BuMQ0kHOt/RO1YzGNswqWMJgivuQ=
|
||||
github.com/docker/cli v29.5.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
||||
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker-credential-helpers v0.9.7 h1:jaPIxEIDz5bQeghNAdzz0ETwMMnM4vzjZlxz3pWP4JA=
|
||||
github.com/docker/docker-credential-helpers v0.9.7/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns=
|
||||
github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs=
|
||||
github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8=
|
||||
github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM=
|
||||
github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo=
|
||||
github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM=
|
||||
github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg=
|
||||
github.com/google/go-github/v84 v84.0.0 h1:I/0Xn5IuChMe8TdmI2bbim5nyhaRFJ7DEdzmD2w+yVA=
|
||||
github.com/google/go-github/v84 v84.0.0/go.mod h1:WwYL1z1ajRdlaPszjVu/47x1L0PXukJBn73xsiYrRRQ=
|
||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo=
|
||||
github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
|
||||
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
|
||||
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
|
||||
github.com/guregu/null/v5 v5.0.0 h1:PRxjqyOekS11W+w/7Vfz6jgJE/BCwELWtgvOJzddimw=
|
||||
github.com/guregu/null/v5 v5.0.0/go.mod h1:SjupzNy+sCPtwQTKWhUCqjhVCO69hpsl2QsZrWHjlwU=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k=
|
||||
github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=
|
||||
github.com/jellydator/validation v1.2.0 h1:z3P3Hk5kdT9epXDraWAfMZtOIUM7UQ0PkNAnFEUjcAk=
|
||||
github.com/jellydator/validation v1.2.0/go.mod h1:AaCjfkQ4Ykdcb+YCwqCtaI3wDsf2UAGhJ06lJs0VgOw=
|
||||
github.com/jellydator/xync v0.0.0-20240601154136-0f038e166df4 h1:8Pm5dsAGaQ+uuGyB705/7epGUStVLe7DFROYdFTq5aA=
|
||||
github.com/jellydator/xync v0.0.0-20240601154136-0f038e166df4/go.mod h1:cf3z0/QbPKWqY/8YJHkvjiJQx1sjVaJfueetY6YHJJk=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
|
||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mailgun/mailgun-go/v5 v5.15.0 h1:ILsBcK5YXUa/U/KIBMWXJk83CPMItqRT0Sip+hstRIQ=
|
||||
github.com/mailgun/mailgun-go/v5 v5.15.0/go.mod h1:+UVQblA5qCh/y1d/mWZZfii3OdGh/6B83E0CKlzF6xU=
|
||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI=
|
||||
github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
|
||||
github.com/meilisearch/meilisearch-go v0.36.2 h1:MYaMPCpdLh2aYPt+zK+19mLoA4dfBY3S1L7T0FADCjU=
|
||||
github.com/meilisearch/meilisearch-go v0.36.2/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8=
|
||||
github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc=
|
||||
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo=
|
||||
github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
|
||||
github.com/orlangure/gnomock v0.32.0 h1:96KCsqbDUaKz2nKkGqC0nIlJeCRaSfEpSi4CljBp8zk=
|
||||
github.com/orlangure/gnomock v0.32.0/go.mod h1:CpMbwyCmPFpeLrsA5LIUcMrGm7LOf9+2JE+taayrUPc=
|
||||
github.com/oxynote/purse v0.0.0-20260525085705-c8f57bdcdeee h1:AaGX+mwuS8wxAU3czDtMkP8rqNalAmn2GOWjHnRhFks=
|
||||
github.com/oxynote/purse v0.0.0-20260525085705-c8f57bdcdeee/go.mod h1:BzVO555hazk20lGO77zniqpHqPndyCWRVk7QwFQ60dw=
|
||||
github.com/oxynote/wetsocks v0.0.0-20260211182956-901c9ce8a66d h1:oS6Yc4ZtioBVdWZbxuLR3uWCvlqAcMie9BoLOwLyF5I=
|
||||
github.com/oxynote/wetsocks v0.0.0-20260211182956-901c9ce8a66d/go.mod h1:BIFVHVAp/MrbnZ9g/heKxkcdPYX3lkuv9x8sG82DSWI=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY=
|
||||
github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/qustavo/sqlhooks/v2 v2.1.0 h1:54yBemHnGHp/7xgT+pxwmIlMSDNYKx5JW5dfRAiCZi0=
|
||||
github.com/qustavo/sqlhooks/v2 v2.1.0/go.mod h1:aMREyKo7fOKTwiLuWPsaHRXEmtqG4yREztO0idF83AU=
|
||||
github.com/rafaeljusto/redigomock v2.4.0+incompatible h1:d7uo5MVINMxnRr20MxbgDkmZ8QRfevjOVgEa4n0OZyY=
|
||||
github.com/rafaeljusto/redigomock v2.4.0+incompatible/go.mod h1:JaY6n2sDr+z2WTsXkOmNRUfDy6FN0L6Nk7x06ndm4tY=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rubenv/sql-migrate v1.8.1 h1:EPNwCvjAowHI3TnZ+4fQu3a915OpnQoPAjTXCGOy2U0=
|
||||
github.com/rubenv/sql-migrate v1.8.1/go.mod h1:BTIKBORjzyxZDS6dzoiw6eAFYJ1iNlGAtjn4LGeVjS8=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/slack-go/slack v0.23.1 h1:ZS5B96wxxYQRwvJ3/vJFtqtUZi3tXhsZCyT44Nv7M80=
|
||||
github.com/slack-go/slack v0.23.1/go.mod h1:H0yR/YBuRJ39RkE+JpV/d/oEsbanzTRowR82bCN0cEs=
|
||||
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI=
|
||||
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0 h1:Mbi5PKN7u322woPa85d7ebZ+SOvEoPvoiBu+ryHWgfA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0/go.mod h1:e7ciERRhZaOZXVjx5MiL8TK5+Xv7G5Gv5PA2ZDEJdL8=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 h1:0UOBWO4dC+e51ui0NFKSPbkHHiQ4TmrEfEZMLDyRmY8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
|
||||
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
|
||||
105
server/core/internal/apps/contregistry/client.go
Normal file
105
server/core/internal/apps/contregistry/client.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package contregistry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/google/go-containerregistry/pkg/authn"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
|
||||
"github.com/oxynote/purse/util/errutil"
|
||||
)
|
||||
|
||||
// ErrUnauthorized is returned when access to the container registry is unauthorized.
|
||||
var ErrUnauthorized = errutil.New(http.StatusBadRequest, "contregistry.unauthorized", "Unauthorized access to the container registry.")
|
||||
|
||||
// digestOptions holds options for configuring the Digest function.
|
||||
type digestOptions struct {
|
||||
username string
|
||||
password string
|
||||
token string
|
||||
}
|
||||
|
||||
// DigestOption is an option for configuring the Digest function.
|
||||
type DigestOption func(*digestOptions)
|
||||
|
||||
// Digest retrieves the digest of the specified container image.
|
||||
func Digest(
|
||||
ctx context.Context,
|
||||
image string,
|
||||
digOpts ...DigestOption,
|
||||
) (string, error) {
|
||||
ref, err := name.ParseReference(
|
||||
image,
|
||||
name.WithDefaultRegistry("index.docker.io"),
|
||||
name.WithDefaultTag("latest"),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parsing image reference %q: %w", image, err)
|
||||
}
|
||||
|
||||
remoteOpts := []remote.Option{
|
||||
remote.WithContext(ctx),
|
||||
}
|
||||
|
||||
baseOpts := &digestOptions{}
|
||||
|
||||
for _, optFn := range digOpts {
|
||||
optFn(baseOpts)
|
||||
}
|
||||
|
||||
if baseOpts.username != "" && baseOpts.password != "" {
|
||||
remoteOpts = append(remoteOpts,
|
||||
remote.WithAuth(
|
||||
&authn.Basic{
|
||||
Username: baseOpts.username,
|
||||
Password: baseOpts.password,
|
||||
},
|
||||
),
|
||||
)
|
||||
} else if baseOpts.token != "" {
|
||||
remoteOpts = append(remoteOpts,
|
||||
remote.WithAuth(
|
||||
&authn.Bearer{
|
||||
Token: baseOpts.token,
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
desc, err := remote.Head(ref, remoteOpts...)
|
||||
if err != nil {
|
||||
var terr *transport.Error
|
||||
|
||||
if ok := errors.As(err, &terr); ok {
|
||||
if slices.ContainsFunc(terr.Errors, func(diag transport.Diagnostic) bool {
|
||||
return diag.Code == transport.UnauthorizedErrorCode || diag.Code == transport.DeniedErrorCode
|
||||
}) {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("getting remote head for %q: %w", ref.Name(), err)
|
||||
}
|
||||
|
||||
return desc.Digest.String(), nil
|
||||
}
|
||||
|
||||
// WithBasicAuth sets the username and password for basic authentication.
|
||||
func WithBasicAuth(username, password string) DigestOption {
|
||||
return func(o *digestOptions) {
|
||||
o.username = username
|
||||
o.password = password
|
||||
}
|
||||
}
|
||||
|
||||
// WithBearerToken sets the token for bearer authentication.
|
||||
func WithBearerToken(token string) DigestOption {
|
||||
return func(o *digestOptions) {
|
||||
o.token = token
|
||||
}
|
||||
}
|
||||
185
server/core/internal/apps/githubapp/content.go
Normal file
185
server/core/internal/apps/githubapp/content.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
package githubapp
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/google/go-github/v72/github"
|
||||
)
|
||||
|
||||
// TreeItemType represents the type of a tree item, either a file or a folder.
|
||||
type TreeItemType string
|
||||
|
||||
const (
|
||||
// TreeItemTypeFile represents a file in the tree.
|
||||
TreeItemTypeFile TreeItemType = "file"
|
||||
|
||||
// TreeItemTypeFolder represents a folder in the tree.
|
||||
TreeItemTypeFolder TreeItemType = "folder"
|
||||
)
|
||||
|
||||
// Repository represents a GitHub repository.
|
||||
type Repository struct {
|
||||
// Name is the name of the repository.
|
||||
Name string `json:"name"`
|
||||
|
||||
// DefaultBranch is the default branch of the repository.
|
||||
DefaultBranch string `json:"defaultBranch"`
|
||||
}
|
||||
|
||||
// Issue represents a GitHub issue.
|
||||
type Issue struct {
|
||||
// ID is the unique identifier of the issue.
|
||||
ID int64 `json:"id"`
|
||||
|
||||
// Title is the title of the issue.
|
||||
Title string `json:"title"`
|
||||
|
||||
// URL is the URL of the issue.
|
||||
URL string `json:"url"`
|
||||
|
||||
// UserID is the ID of the user who created the issue.
|
||||
UserID int64 `json:"userId"`
|
||||
|
||||
// Draft indicates whether the issue is a draft.
|
||||
Draft bool `json:"draft"`
|
||||
|
||||
// State is the current state of the issue, such as "open" or "closed".
|
||||
State string `json:"state"`
|
||||
|
||||
// UpdatedAt is the timestamp when the issue was last updated.
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
|
||||
// CreatedAt is the timestamp when the issue was created.
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// FileContent represents the content of a file in a GitHub repository.
|
||||
type FileContent struct {
|
||||
// Path is the file path within the repository.
|
||||
Path string `json:"path"`
|
||||
|
||||
// Content is the decoded text content of the file.
|
||||
Content string `json:"content"`
|
||||
|
||||
// SHA is the git SHA of the file.
|
||||
SHA string `json:"sha"`
|
||||
|
||||
// Size is the size of the file in bytes.
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
// CodeSearchResult represents a single code search result.
|
||||
type CodeSearchResult struct {
|
||||
// Name is the filename.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Path is the file path within the repository.
|
||||
Path string `json:"path"`
|
||||
|
||||
// URL is the HTML URL of the file.
|
||||
URL string `json:"url"`
|
||||
|
||||
// Fragments contains text fragments showing where the match occurred.
|
||||
Fragments []string `json:"fragments,omitempty"`
|
||||
}
|
||||
|
||||
// Tree represents a Git tree structure.
|
||||
type Tree []TreeItem
|
||||
|
||||
// GetItem retrieves a TreeItem from the tree based on the given path.
|
||||
func (t Tree) GetItem(path string) (TreeItem, bool) {
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
var items = t
|
||||
|
||||
for i := range parts {
|
||||
j := slices.IndexFunc(items, func(ti TreeItem) bool {
|
||||
return ti.Name == strings.Join(parts[:i+1], "/")
|
||||
})
|
||||
if j == -1 {
|
||||
return TreeItem{}, false
|
||||
}
|
||||
|
||||
item := items[j]
|
||||
|
||||
if i != len(parts)-1 {
|
||||
if item.Type != TreeItemTypeFolder {
|
||||
return TreeItem{}, false
|
||||
}
|
||||
|
||||
items = item.Items
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return item, true
|
||||
}
|
||||
|
||||
return TreeItem{}, false
|
||||
}
|
||||
|
||||
// TreeItem represents an item in a Git tree structure.
|
||||
type TreeItem struct {
|
||||
// Type is the type of the tree item.
|
||||
Type TreeItemType `json:"type"`
|
||||
|
||||
// Name is the file path of the tree item.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Items is a list of child tree items, if any.
|
||||
Items []TreeItem `json:"items,omitempty"`
|
||||
|
||||
// Checksum is the checksum of the item.
|
||||
Checksum string `json:"checksum"`
|
||||
}
|
||||
|
||||
// ParseTreeItems converts a list of GitHub TreeEntry objects into a
|
||||
// list of TreeItem objects.
|
||||
func ParseTreeItems(items []*github.TreeEntry) []TreeItem {
|
||||
var (
|
||||
res []TreeItem
|
||||
parents = make(map[string]*TreeItem)
|
||||
)
|
||||
|
||||
for _, item := range items {
|
||||
path := item.GetPath()
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
item := &TreeItem{
|
||||
Type: parseTreeItemType(item.GetType()),
|
||||
Name: path,
|
||||
Checksum: item.GetSHA(),
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
res = append(res, *item)
|
||||
|
||||
if item.Type == TreeItemTypeFolder {
|
||||
parents[item.Name] = &res[len(res)-1]
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
parentPath := strings.Join(parts[:len(parts)-1], "/")
|
||||
|
||||
parent := parents[parentPath]
|
||||
parent.Items = append(parent.Items, *item)
|
||||
|
||||
if item.Type == TreeItemTypeFolder {
|
||||
parents[path] = &parent.Items[len(parent.Items)-1]
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// parseTreeItemType converts a GitHub tree entry type string to a TreeItemType.
|
||||
func parseTreeItemType(tp string) TreeItemType {
|
||||
if tp == "tree" {
|
||||
return TreeItemTypeFolder
|
||||
}
|
||||
|
||||
return TreeItemTypeFile
|
||||
}
|
||||
76
server/core/internal/apps/githubapp/installation.go
Normal file
76
server/core/internal/apps/githubapp/installation.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package githubapp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/oxynote/purse/util/cryptoutil"
|
||||
)
|
||||
|
||||
// githubHost is the GitHub host.
|
||||
const _githubHost = "github.com"
|
||||
|
||||
// _installationStateTTL is the time-to-live for the installation state.
|
||||
const _installationStateTTL = time.Minute * 15
|
||||
|
||||
// InstallationState represents the state of a GitHub App installation.
|
||||
type InstallationState struct {
|
||||
// OrganizationID is the ID of the organization
|
||||
// where the GitHub App is installed.
|
||||
OrganizationID string `json:"organizationId"`
|
||||
|
||||
// CreatedAt is the time when the installation was created.
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// CreateInstallationURL generates the GitHub App installation URL for the
|
||||
// specified organization ID.
|
||||
func (m *Manager) CreateInstallationURL(organizationID string) (string, error) {
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: _githubHost,
|
||||
Path: "/apps/" + m.opt.AppSlug + "/installations/new",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(InstallationState{
|
||||
OrganizationID: organizationID,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal installation state: %w", err)
|
||||
}
|
||||
|
||||
state, err := cryptoutil.EncryptText(string(data), []byte(m.opt.InstallationSigningSecret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt installation state: %w", err)
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
q.Add("state", state)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// VerifyInstallationState verifies and decrypts the installation state
|
||||
// from the given state string.
|
||||
func (m *Manager) VerifyInstallationState(state string) (*InstallationState, error) {
|
||||
decrypted, err := cryptoutil.DecryptText(state, []byte(m.opt.InstallationSigningSecret))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt installation state: %w", err)
|
||||
}
|
||||
|
||||
var is InstallationState
|
||||
|
||||
if err := json.Unmarshal([]byte(decrypted), &is); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal installation state: %w", err)
|
||||
}
|
||||
|
||||
if time.Since(is.CreatedAt) > _installationStateTTL {
|
||||
return nil, fmt.Errorf("installation state has expired")
|
||||
}
|
||||
|
||||
return &is, nil
|
||||
}
|
||||
445
server/core/internal/apps/githubapp/manager.go
Normal file
445
server/core/internal/apps/githubapp/manager.go
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
package githubapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/bradleyfalzon/ghinstallation/v2"
|
||||
"github.com/google/go-github/v72/github"
|
||||
"github.com/oxynote/purse/util/errutil"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrResourceNotFound is a generic error returned when a GitHub repository tree resource is not found.
|
||||
ErrResourceNotFound = errutil.New(http.StatusNotFound, "github.resource_not_found", "resource not found")
|
||||
|
||||
// ErrRepositoryNotFound is returned when a GitHub repository resource is not found.
|
||||
ErrRepositoryNotFound = errutil.New(http.StatusNotFound, "github.repository_not_found", "repository not found")
|
||||
|
||||
// ErrRepositoryBranchNotFound is returned when a GitHub branch resource is not found.
|
||||
ErrRepositoryBranchNotFound = errutil.New(http.StatusNotFound, "github.repository_branch_not_found", "repository branch not found")
|
||||
|
||||
// ErrInstallationNotFound is returned when a GitHub installation resource is not found.
|
||||
ErrInstallationNotFound = errutil.New(http.StatusNotFound, "github.installation_not_found", "installation not found")
|
||||
)
|
||||
|
||||
// Options holds configuration options for the Github handler.
|
||||
type Options struct {
|
||||
// AppID is the ID of the Github App.
|
||||
AppID int64
|
||||
|
||||
// AppSlug is the slug of the Github App.
|
||||
AppSlug string
|
||||
|
||||
// SignatureSecret is the secret used to verify Github request signatures.
|
||||
SignatureSecret string
|
||||
|
||||
// PrivateKeyPath is the path to the private key used for Github App authentication.
|
||||
PrivateKeyPath string
|
||||
|
||||
// InstallationSigningSecret is the secret used to sign installation tokens.
|
||||
InstallationSigningSecret string
|
||||
}
|
||||
|
||||
// Manager represents a GitHub App clients manager.
|
||||
type Manager struct {
|
||||
db DB
|
||||
appClient *github.Client
|
||||
opt Options
|
||||
}
|
||||
|
||||
// NewManager creates a new GitHub App client with the given app ID and private key path.
|
||||
func NewManager(db DB, opt Options) (*Manager, error) {
|
||||
appClient, err := createAppClient(opt.AppID, opt.PrivateKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
db: db,
|
||||
opt: opt,
|
||||
appClient: appClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SignatureSecret returns the signature secret for verifying GitHub request signatures.
|
||||
func (m *Manager) SignatureSecret() string {
|
||||
return m.opt.SignatureSecret
|
||||
}
|
||||
|
||||
// HasInstallationClient checks whether InstallationClient exists for the given organization ID.
|
||||
func (m *Manager) HasInstallationClient(ctx context.Context, organizationID string) (bool, error) {
|
||||
installationID, err := m.db.FetchGithubInstallationByOrganizationID(ctx, organizationID)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
// OK.
|
||||
case errutil.IsNotFound(err):
|
||||
return false, nil
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, err = m.createInstallationClient(installationID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
inst, resp, err := m.appClient.Apps.GetInstallation(ctx, installationID)
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, parseGithubError(err)
|
||||
}
|
||||
|
||||
account := inst.GetAccount()
|
||||
if account == nil || account.Login == nil || account.Type == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetInstallationClient fetches an InstallationClient for the given installation ID.
|
||||
func (m *Manager) GetInstallationClient(ctx context.Context, organizationID string) (*InstallationClient, error) {
|
||||
installationID, err := m.db.FetchGithubInstallationByOrganizationID(ctx, organizationID)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
// OK.
|
||||
case errutil.IsNotFound(err):
|
||||
return nil, ErrInstallationNotFound
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
|
||||
installationClient, err := m.createInstallationClient(installationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inst, resp, err := m.appClient.Apps.GetInstallation(ctx, installationID)
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrInstallationNotFound
|
||||
}
|
||||
|
||||
return nil, parseGithubError(err)
|
||||
}
|
||||
|
||||
account := inst.GetAccount()
|
||||
if account == nil || account.Login == nil || account.Type == nil {
|
||||
return nil, ErrInstallationNotFound
|
||||
}
|
||||
|
||||
return &InstallationClient{
|
||||
user: account.GetType() == "User",
|
||||
owner: account.GetLogin(),
|
||||
installationClient: installationClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createInstallationClient creates a new Github client using the installation transport.
|
||||
func (m *Manager) createInstallationClient(installationID int64) (*github.Client, error) {
|
||||
rt, err := ghinstallation.NewKeyFromFile(
|
||||
http.DefaultTransport,
|
||||
m.opt.AppID,
|
||||
installationID,
|
||||
m.opt.PrivateKeyPath,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create installation transport: %w", err)
|
||||
}
|
||||
|
||||
return github.NewClient(
|
||||
&http.Client{
|
||||
Transport: rt,
|
||||
},
|
||||
), nil
|
||||
}
|
||||
|
||||
// InstallationClient represents a GitHub App installation client.
|
||||
type InstallationClient struct {
|
||||
user bool
|
||||
owner string
|
||||
installationClient *github.Client
|
||||
}
|
||||
|
||||
// FetchIssues fetches issues from the GitHub repository based on the provided query and repository name.
|
||||
func (ic *InstallationClient) FetchIssues(ctx context.Context, q, repository string) ([]Issue, error) {
|
||||
bq := "in:title"
|
||||
|
||||
if repository != "" {
|
||||
_, resp, err := ic.installationClient.Repositories.Get(ctx, ic.owner, repository)
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrRepositoryNotFound
|
||||
}
|
||||
|
||||
return nil, parseGithubError(err)
|
||||
}
|
||||
|
||||
bq = "repo:" + ic.owner + "/" + repository + " " + bq
|
||||
} else {
|
||||
if ic.user {
|
||||
bq = "user:" + ic.owner + " " + bq
|
||||
} else {
|
||||
bq = "org:" + ic.owner + " " + bq
|
||||
}
|
||||
}
|
||||
|
||||
bq += " " + q
|
||||
|
||||
res, _, err := ic.installationClient.Search.Issues(ctx, bq, nil)
|
||||
if err != nil {
|
||||
return nil, parseGithubError(err)
|
||||
}
|
||||
|
||||
var issues []Issue
|
||||
|
||||
for _, issue := range res.Issues {
|
||||
issues = append(issues, Issue{
|
||||
ID: issue.GetID(),
|
||||
Title: issue.GetTitle(),
|
||||
URL: issue.GetHTMLURL(),
|
||||
UserID: issue.GetUser().GetID(),
|
||||
Draft: issue.GetDraft(),
|
||||
State: issue.GetState(),
|
||||
UpdatedAt: issue.GetUpdatedAt().Format("2006-01-02T15:04:05Z"),
|
||||
CreatedAt: issue.GetCreatedAt().Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// FetchRepositories fetches all repositories accessible by the GitHub App installation.
|
||||
func (ic *InstallationClient) FetchRepositories(ctx context.Context) ([]Repository, error) {
|
||||
repos, _, err := ic.installationClient.Repositories.ListByOrg(
|
||||
ctx,
|
||||
ic.owner,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, parseGithubError(err)
|
||||
}
|
||||
|
||||
var res []Repository
|
||||
|
||||
for _, repo := range repos {
|
||||
res = append(res, Repository{
|
||||
Name: repo.GetName(),
|
||||
DefaultBranch: repo.GetDefaultBranch(),
|
||||
})
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// FetchRepositoryBranches fetches all branches of the specified repository.
|
||||
func (ic *InstallationClient) FetchRepositoryBranches(ctx context.Context, repository string) ([]string, error) {
|
||||
_, resp, err := ic.installationClient.Repositories.Get(ctx, ic.owner, repository)
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrRepositoryNotFound
|
||||
}
|
||||
|
||||
return nil, parseGithubError(err)
|
||||
}
|
||||
|
||||
branches, _, err := ic.installationClient.Repositories.ListBranches(
|
||||
ctx,
|
||||
ic.owner,
|
||||
repository,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, parseGithubError(err)
|
||||
}
|
||||
|
||||
var res []string
|
||||
|
||||
for _, branch := range branches {
|
||||
res = append(res, branch.GetName())
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// FetchRepositoryTree fetches the full recursive file tree of the specified branch in the repository.
|
||||
func (ic *InstallationClient) FetchRepositoryTree(ctx context.Context, repository, branch string) (Tree, error) {
|
||||
repo, resp, err := ic.installationClient.Repositories.Get(ctx, ic.owner, repository)
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrRepositoryNotFound
|
||||
}
|
||||
|
||||
return nil, parseGithubError(err)
|
||||
}
|
||||
|
||||
if branch == "" {
|
||||
branch = repo.GetDefaultBranch()
|
||||
}
|
||||
|
||||
_, resp, err = ic.installationClient.Repositories.GetBranch(
|
||||
ctx,
|
||||
ic.owner,
|
||||
repository,
|
||||
branch,
|
||||
3,
|
||||
)
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrRepositoryBranchNotFound
|
||||
}
|
||||
|
||||
return nil, parseGithubError(err)
|
||||
}
|
||||
|
||||
tree, _, err := ic.installationClient.Git.GetTree(
|
||||
ctx,
|
||||
ic.owner,
|
||||
repository,
|
||||
branch,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, parseGithubError(err)
|
||||
}
|
||||
|
||||
return ParseTreeItems(tree.Entries), nil
|
||||
}
|
||||
|
||||
// FetchFileContent fetches the content of a file from the specified repository and branch.
|
||||
func (ic *InstallationClient) FetchFileContent(ctx context.Context, repository, branch, path string) (*FileContent, error) {
|
||||
repo, resp, err := ic.installationClient.Repositories.Get(ctx, ic.owner, repository)
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrRepositoryNotFound
|
||||
}
|
||||
|
||||
return nil, parseGithubError(err)
|
||||
}
|
||||
|
||||
if branch == "" {
|
||||
branch = repo.GetDefaultBranch()
|
||||
}
|
||||
|
||||
fc, _, resp, err := ic.installationClient.Repositories.GetContents(
|
||||
ctx,
|
||||
ic.owner,
|
||||
repository,
|
||||
path,
|
||||
&github.RepositoryContentGetOptions{
|
||||
Ref: branch,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
|
||||
return nil, parseGithubError(err)
|
||||
}
|
||||
|
||||
if fc == nil {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
|
||||
content, err := fc.GetContent()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode file content: %w", err)
|
||||
}
|
||||
|
||||
return &FileContent{
|
||||
Path: fc.GetPath(),
|
||||
Content: content,
|
||||
SHA: fc.GetSHA(),
|
||||
Size: fc.GetSize(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FetchCodeSearchResults searches for code in the specified repository.
|
||||
func (ic *InstallationClient) FetchCodeSearchResults(ctx context.Context, repository, query string) ([]CodeSearchResult, error) {
|
||||
_, resp, err := ic.installationClient.Repositories.Get(ctx, ic.owner, repository)
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrRepositoryNotFound
|
||||
}
|
||||
|
||||
return nil, parseGithubError(err)
|
||||
}
|
||||
|
||||
q := "repo:" + ic.owner + "/" + repository + " " + query
|
||||
|
||||
res, _, err := ic.installationClient.Search.Code(ctx, q, &github.SearchOptions{
|
||||
TextMatch: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, parseGithubError(err)
|
||||
}
|
||||
|
||||
var results []CodeSearchResult
|
||||
|
||||
for _, cr := range res.CodeResults {
|
||||
item := CodeSearchResult{
|
||||
Name: cr.GetName(),
|
||||
Path: cr.GetPath(),
|
||||
URL: cr.GetHTMLURL(),
|
||||
}
|
||||
|
||||
for _, tm := range cr.TextMatches {
|
||||
if f := tm.GetFragment(); f != "" {
|
||||
item.Fragments = append(item.Fragments, f)
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, item)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// createAppClient creates a new Github client using the app transport for GitHub Apps.
|
||||
func createAppClient(appID int64, privateKeyPath string) (*github.Client, error) {
|
||||
rt, err := ghinstallation.NewAppsTransportKeyFromFile(
|
||||
http.DefaultTransport,
|
||||
appID,
|
||||
privateKeyPath,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create installation transport: %w", err)
|
||||
}
|
||||
|
||||
return github.NewClient(
|
||||
&http.Client{
|
||||
Transport: rt,
|
||||
},
|
||||
), nil
|
||||
}
|
||||
|
||||
// parseGithubError parses errors returned by the Github API and maps them to appropriate HTTP status codes.
|
||||
func parseGithubError(err error) error {
|
||||
switch resp := err.(type) {
|
||||
case *github.RateLimitError:
|
||||
return errutil.New(http.StatusTooManyRequests, "github.rate_limit", "rate limit exceeded")
|
||||
case *github.AbuseRateLimitError:
|
||||
return errutil.New(http.StatusTooManyRequests, "github.abuse_rate_limit", "abuse rate limit exceeded")
|
||||
case *github.ErrorResponse:
|
||||
if resp.Response.StatusCode == http.StatusNotFound {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DB is an interface that handles communication with the document database.
|
||||
type DB interface {
|
||||
// FetchGithubInstallationByOrganizationID retrieves the installation ID for a given organization ID.
|
||||
FetchGithubInstallationByOrganizationID(ctx context.Context, organizationID string) (int64, error)
|
||||
}
|
||||
124
server/core/internal/apps/slackapp/content.go
Normal file
124
server/core/internal/apps/slackapp/content.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
package slackapp
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/oxynote/purse/util/timeutil"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// App represents a Slack application with its team ID.
|
||||
type App struct {
|
||||
// TeamID is the identifier for the Slack team associated with the app.
|
||||
TeamID string `json:"teamId" db:"team_id"`
|
||||
|
||||
// OrganizationID is the identifier for the organization associated with the app.
|
||||
OrganizationID null.String `json:"organizationId" db:"fk_organization_id"`
|
||||
|
||||
// Token is the access token for the Slack app.
|
||||
Token string `json:"token" db:"token"`
|
||||
}
|
||||
|
||||
// Message represents a message in the Slack app.
|
||||
type Message struct {
|
||||
// ID is the unique identifier for the message.
|
||||
ID xid.ID `json:"id" db:"id"`
|
||||
|
||||
// OrganizationID is the identifier for the organization associated with the message.
|
||||
OrganizationID string `json:"organizationId" db:"fk_organization_id"`
|
||||
|
||||
// Text is the content of the message.
|
||||
Text string `json:"text" db:"text"`
|
||||
|
||||
// CreatedAt is the timestamp when the message was created.
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
}
|
||||
|
||||
// NewMessage creates a new message instance with the given organization ID and text.
|
||||
func NewMessage(organizationID, text string) *Message {
|
||||
return &Message{
|
||||
ID: xid.New(),
|
||||
OrganizationID: organizationID,
|
||||
Text: text,
|
||||
CreatedAt: timeutil.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// UserLink represents a link between a Slack user and internal app user.
|
||||
type UserLink struct {
|
||||
// SlackUserID is the Slack user identifier.
|
||||
SlackUserID string `json:"slackUserId" db:"slack_user_id"`
|
||||
|
||||
// TeamID is the Slack team identifier.
|
||||
TeamID string `json:"teamId" db:"fk_team_id"`
|
||||
|
||||
// UserID is the internal user identifier.
|
||||
UserID string `json:"userId" db:"fk_user_id"`
|
||||
|
||||
// Settings contains the settings for the user link.
|
||||
Settings UserLinkSettings `json:"settings" db:"settings"`
|
||||
|
||||
// CreatedAt is the timestamp when the link was created.
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
}
|
||||
|
||||
// NewUserLink creates a new user link instance.
|
||||
func NewUserLink(slackUserID, teamID, userID string) *UserLink {
|
||||
return &UserLink{
|
||||
SlackUserID: slackUserID,
|
||||
TeamID: teamID,
|
||||
UserID: userID,
|
||||
Settings: UserLinkSettings{
|
||||
Notifications: true,
|
||||
},
|
||||
CreatedAt: timeutil.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// UserLinkSettings represents settings for a Slack user link.
|
||||
type UserLinkSettings struct {
|
||||
// Notifications indicates whether notifications are enabled for the user link.
|
||||
Notifications bool `json:"notifications"`
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface for database storage.
|
||||
// It converts the UserLinkSettings to JSON for storage in the database.
|
||||
func (uls UserLinkSettings) Value() (driver.Value, error) {
|
||||
return json.Marshal(uls)
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface for database retrieval.
|
||||
// It converts JSON data from the database back to UserLinkSettings.
|
||||
func (uls *UserLinkSettings) Scan(value any) error {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var data []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
data = v
|
||||
case string:
|
||||
data = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("cannot scan %T into UserLinkSettings", value)
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, uls)
|
||||
}
|
||||
|
||||
// AppAccess represents access information for a Slack app.
|
||||
type AppAccess struct {
|
||||
// TeamID is the identifier for the Slack team.
|
||||
TeamID string `json:"teamId"`
|
||||
|
||||
// AccessToken is the access token for the Slack app.
|
||||
AccessToken string `json:"accessToken"`
|
||||
|
||||
// AppID is the identifier for the Slack app.
|
||||
AppID string `json:"appId"`
|
||||
}
|
||||
128
server/core/internal/apps/slackapp/installation.go
Normal file
128
server/core/internal/apps/slackapp/installation.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package slackapp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/oxynote/purse/util/cryptoutil"
|
||||
)
|
||||
|
||||
// _slackHost is the Slack host.
|
||||
const _slackHost = "slack.com"
|
||||
|
||||
// _installationStateTTL is the time-to-live for the installation state.
|
||||
const _installationStateTTL = time.Minute * 15
|
||||
|
||||
// botScopes are the OAuth scopes required for bot-level access.
|
||||
var botScopes = []string{
|
||||
"app_mentions:read",
|
||||
"chat:write",
|
||||
"commands",
|
||||
"im:write",
|
||||
"users:read",
|
||||
}
|
||||
|
||||
// userScopes are the OAuth scopes required for user-level access.
|
||||
var userScopes = []string{
|
||||
"im:read",
|
||||
}
|
||||
|
||||
// InstallationState represents the state of a Slack App installation.
|
||||
type InstallationState struct {
|
||||
// TeamID specifies the team id which needs to be connected. It's
|
||||
// only present in case the installation is done internally.
|
||||
TeamID null.String `json:"teamId"`
|
||||
|
||||
// OrganizationID is the ID of the organization
|
||||
// where the Slack App is installed.
|
||||
OrganizationID null.String `json:"organizationId"`
|
||||
|
||||
// CreatedAt is the time when the installation was created.
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// CreateExternalInstallationURL generates the Slack installation URL for the
|
||||
// specified organization ID. This is used when installing Slack from the app.
|
||||
func (m *Manager) CreateExternalInstallationURL(organizationID string) (string, error) {
|
||||
u := url.URL{
|
||||
Scheme: "https",
|
||||
Host: _slackHost,
|
||||
Path: "/oauth/v2/authorize",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(InstallationState{
|
||||
OrganizationID: null.StringFrom(organizationID),
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal installation state: %w", err)
|
||||
}
|
||||
|
||||
state, err := cryptoutil.EncryptText(string(data), []byte(m.opt.InstallationSigningSecret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt installation state: %w", err)
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
q.Set("client_id", m.opt.ClientID)
|
||||
q.Set("state", state)
|
||||
q.Set("scope", strings.Join(botScopes, ","))
|
||||
q.Set("user_scope", strings.Join(userScopes, ","))
|
||||
q.Set("redirect_uri", m.opt.RedirectURL)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// CreateInternalInstallationURL generates the Slack installation URL for the
|
||||
// specified team ID. This is used when connecting from within Slack.
|
||||
func (m *Manager) CreateInternalInstallationURL(teamID string) (string, error) {
|
||||
u, err := url.Parse(m.opt.RedirectURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse installation URL: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(InstallationState{
|
||||
TeamID: null.StringFrom(teamID),
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal installation state: %w", err)
|
||||
}
|
||||
|
||||
state, err := cryptoutil.EncryptText(string(data), []byte(m.opt.InstallationSigningSecret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt installation state: %w", err)
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
q.Set("state", state)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// VerifyInstallationState verifies and decrypts the installation state
|
||||
// from the given state string.
|
||||
func (m *Manager) VerifyInstallationState(state string) (*InstallationState, error) {
|
||||
decrypted, err := cryptoutil.DecryptText(state, []byte(m.opt.InstallationSigningSecret))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt installation state: %w", err)
|
||||
}
|
||||
|
||||
var is InstallationState
|
||||
|
||||
if err := json.Unmarshal([]byte(decrypted), &is); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal installation state: %w", err)
|
||||
}
|
||||
|
||||
if time.Since(is.CreatedAt) > _installationStateTTL {
|
||||
return nil, fmt.Errorf("installation state has expired")
|
||||
}
|
||||
|
||||
return &is, nil
|
||||
}
|
||||
86
server/core/internal/apps/slackapp/link.go
Normal file
86
server/core/internal/apps/slackapp/link.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package slackapp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/oxynote/purse/util/cryptoutil"
|
||||
"github.com/oxynote/purse/util/errutil"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrLinkStateExpired is returned when the link state has expired.
|
||||
ErrLinkStateExpired = errutil.New(http.StatusBadRequest, "slack.link_state_expired", "link state has expired")
|
||||
)
|
||||
|
||||
// _linkStateTTL is the time-to-live for the link state.
|
||||
const _linkStateTTL = time.Minute * 15
|
||||
|
||||
// LinkState represents the state of a Slack user linking request.
|
||||
type LinkState struct {
|
||||
// SlackUserID is the Slack user identifier.
|
||||
SlackUserID string `json:"slackUserId"`
|
||||
|
||||
// TeamID is the Slack team identifier.
|
||||
TeamID string `json:"teamId"`
|
||||
|
||||
// OrganizationID is the ID of the organization
|
||||
// where the Slack App is installed.
|
||||
OrganizationID string `json:"organizationId"`
|
||||
|
||||
// CreatedAt is the time when the link request was created.
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// CreateLinkURL generates a URL for linking a Slack user to an internal account.
|
||||
func (m *Manager) CreateLinkURL(slackUserID, slackTeamID, organizationID string) (string, error) {
|
||||
data, err := json.Marshal(LinkState{
|
||||
SlackUserID: slackUserID,
|
||||
TeamID: slackTeamID,
|
||||
OrganizationID: organizationID,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal link state: %w", err)
|
||||
}
|
||||
|
||||
state, err := cryptoutil.EncryptText(string(data), []byte(m.opt.InstallationSigningSecret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt link state: %w", err)
|
||||
}
|
||||
|
||||
u, err := url.Parse(m.opt.RedirectURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse link user URL: %w", err)
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
q.Set("state", state)
|
||||
q.Set("user", "1")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// VerifyLinkState verifies and decrypts the link state from the given state string.
|
||||
func (m *Manager) VerifyLinkState(state string) (*LinkState, error) {
|
||||
decrypted, err := cryptoutil.DecryptText(state, []byte(m.opt.InstallationSigningSecret))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt link state: %w", err)
|
||||
}
|
||||
|
||||
var ls LinkState
|
||||
|
||||
if err := json.Unmarshal([]byte(decrypted), &ls); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal link state: %w", err)
|
||||
}
|
||||
|
||||
if time.Since(ls.CreatedAt) > _linkStateTTL {
|
||||
return nil, ErrLinkStateExpired
|
||||
}
|
||||
|
||||
return &ls, nil
|
||||
}
|
||||
246
server/core/internal/apps/slackapp/manager.go
Normal file
246
server/core/internal/apps/slackapp/manager.go
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
package slackapp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/oxynote/heimdall/internal/notification"
|
||||
"github.com/oxynote/heimdall/internal/notification/interpreter"
|
||||
"github.com/oxynote/purse/http/httpserver"
|
||||
"github.com/oxynote/purse/util/errutil"
|
||||
"github.com/slack-go/slack"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAppNotFound is returned when a Slack app is not found.
|
||||
ErrAppNotFound = errutil.New(http.StatusNotFound, "slack.app_not_found", "slack app not found")
|
||||
|
||||
// ErrAppNotConnected is returned when a Slack app is not connected to an organization.
|
||||
ErrAppNotConnected = errutil.New(http.StatusBadRequest, "slack.app_not_connected", "slack app is not connected to an organization")
|
||||
)
|
||||
|
||||
// Options holds configuration options for the Slack manager.
|
||||
type Options struct {
|
||||
// RedirectURL is the URL to redirect users to connect their Slack organization.
|
||||
RedirectURL string
|
||||
|
||||
// ClientID is the Slack client ID for OAuth.
|
||||
ClientID string
|
||||
|
||||
// ClientSecret is the Slack client secret for OAuth.
|
||||
ClientSecret string
|
||||
|
||||
// SignatureSecret is the secret used to verify Slack request signatures.
|
||||
SignatureSecret string
|
||||
|
||||
// InstallationSigningSecret is the secret used to sign installation tokens.
|
||||
InstallationSigningSecret string
|
||||
}
|
||||
|
||||
// Validate checks if the required options are set for the Slack manager.
|
||||
func (o Options) Validate() error {
|
||||
if o.ClientID == "" {
|
||||
return errors.New("client id is required")
|
||||
}
|
||||
|
||||
if o.ClientSecret == "" {
|
||||
return errors.New("client secret is required")
|
||||
}
|
||||
|
||||
if o.SignatureSecret == "" {
|
||||
return errors.New("signature secret is required")
|
||||
}
|
||||
|
||||
if o.RedirectURL == "" {
|
||||
return errors.New("redirect URL is required")
|
||||
}
|
||||
|
||||
if o.InstallationSigningSecret == "" {
|
||||
return errors.New("installation signing secret is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Manager represents a Slack App manager for handling Slack integrations.
|
||||
type Manager struct {
|
||||
log *slog.Logger
|
||||
db DB
|
||||
int Interpreter
|
||||
opt Options
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
// NewManager creates a new Slack App manager with the given options.
|
||||
func NewManager(
|
||||
log *slog.Logger,
|
||||
db DB,
|
||||
int Interpreter,
|
||||
notifs notification.NotificationReceiver,
|
||||
opt Options,
|
||||
) (*Manager, error) {
|
||||
if err := opt.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
log: log.With("component", "slack-app-manager"),
|
||||
db: db,
|
||||
int: int,
|
||||
opt: opt,
|
||||
}
|
||||
|
||||
m.cleanup = notifs.OnNotification(m.ProcessNotification)
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Close stops and closes all processes.
|
||||
func (m *Manager) Close() error {
|
||||
if m.cleanup != nil {
|
||||
m.cleanup()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessNotification processes and sends a notification to the appropriate Slack user.
|
||||
func (m *Manager) ProcessNotification(ctx context.Context, n notification.Notification) {
|
||||
ul, err := m.db.FetchSlackUserLinkByUserID(ctx, n.UserID, n.OrganizationID)
|
||||
if err != nil {
|
||||
if !errutil.IsNotFound(err) {
|
||||
m.log.Error(
|
||||
"failed to fetch slack user link",
|
||||
slog.String("userID", n.UserID),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !ul.Settings.Notifications {
|
||||
return
|
||||
}
|
||||
|
||||
client, err := m.GetClient(ctx, ul.TeamID)
|
||||
if err != nil {
|
||||
m.log.Error(
|
||||
"failed to get slack client",
|
||||
slog.String("userID", n.UserID),
|
||||
slog.String("teamID", ul.TeamID),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := m.int.InterpretNotification(ctx, n)
|
||||
if err != nil {
|
||||
m.log.Error(
|
||||
"failed to interpret notification",
|
||||
slog.String("userID", n.UserID),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _, err = client.PostMessageContext(
|
||||
ctx,
|
||||
ul.SlackUserID,
|
||||
slack.MsgOptionText(msg.Text, false),
|
||||
)
|
||||
if err != nil {
|
||||
m.log.Error(
|
||||
"failed to send slack notification",
|
||||
slog.String("userID", n.UserID),
|
||||
slog.String("teamID", ul.TeamID),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// SignatureSecret returns the signature secret for verifying Slack request signatures.
|
||||
func (m *Manager) VerifyMiddleware(r *http.Request) error {
|
||||
verifier, err := slack.NewSecretsVerifier(r.Header, m.opt.SignatureSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reqBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return httpserver.ErrInvalidJSON
|
||||
}
|
||||
|
||||
// The body is read and replaced to ensure that the request
|
||||
// can be processed later.
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
|
||||
|
||||
if _, err = verifier.Write(reqBody); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = verifier.Ensure(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExchangeCode exchanges an OAuth code for an app access data.
|
||||
func (m *Manager) ExchangeCode(ctx context.Context, code string) (*AppAccess, error) {
|
||||
resp, err := slack.GetOAuthV2ResponseContext(
|
||||
ctx,
|
||||
http.DefaultClient,
|
||||
m.opt.ClientID,
|
||||
m.opt.ClientSecret,
|
||||
code,
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AppAccess{
|
||||
TeamID: resp.Team.ID,
|
||||
AccessToken: resp.AccessToken,
|
||||
AppID: resp.AppID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClient creates a Slack client for the given team ID.
|
||||
func (m *Manager) GetClient(ctx context.Context, teamID string) (*slack.Client, error) {
|
||||
app, err := m.db.FetchSlackAppByTeamID(ctx, teamID)
|
||||
if err != nil {
|
||||
if errutil.IsNotFound(err) {
|
||||
return nil, ErrAppNotFound
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return slack.New(app.Token), nil
|
||||
}
|
||||
|
||||
// DB is an interface that handles communication with the database.
|
||||
type DB interface {
|
||||
// FetchSlackAppByTeamID fetches the slack app for a given team id.
|
||||
FetchSlackAppByTeamID(ctx context.Context, teamID string) (*App, error)
|
||||
|
||||
// FetchSlackAppByOrganizationID retrieves the slack app for a given organization ID.
|
||||
FetchSlackAppByOrganizationID(ctx context.Context, organizationID string) (*App, error)
|
||||
|
||||
// FetchSlackUserLinkByUserID fetches the slack user link for a given user id.
|
||||
FetchSlackUserLinkByUserID(ctx context.Context, userID, organizationID string) (*UserLink, error)
|
||||
}
|
||||
|
||||
// Interpreter interprets notifications into human-readable messages.
|
||||
type Interpreter interface {
|
||||
// InterpretNotification interprets a notification and returns a human-readable message.
|
||||
InterpretNotification(ctx context.Context, n notification.Notification) (*interpreter.Message, error)
|
||||
}
|
||||
181
server/core/internal/apps/webchanges/client.go
Normal file
181
server/core/internal/apps/webchanges/client.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
package webchanges
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is a client for the changedetection.io API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new changedetection.io API client.
|
||||
func NewClient(baseURL, apiKey string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
client: http.DefaultClient,
|
||||
}
|
||||
}
|
||||
|
||||
// FetchWatcher fetches the watch with the given UUID.
|
||||
func (c *Client) FetchWatcher(ctx context.Context, uuid string) (*Watch, error) {
|
||||
url := c.baseURL + "/api/v1/watch/" + uuid
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("x-api-key", c.apiKey)
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, c.parseErrorResponse(resp)
|
||||
}
|
||||
|
||||
var watch rawWatch
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&watch); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
var unreachable bool
|
||||
|
||||
switch werr := watch.LastError.(type) {
|
||||
case bool:
|
||||
unreachable = werr
|
||||
case string:
|
||||
unreachable = werr != ""
|
||||
default:
|
||||
// Unknown type, assume unreachable if not nil.
|
||||
unreachable = werr != nil
|
||||
}
|
||||
|
||||
return &Watch{
|
||||
URL: watch.URL,
|
||||
Unreachable: unreachable,
|
||||
LastChangedAt: time.Unix(watch.LastChanged, 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateWatcher creates a new watch and returns its uuid.
|
||||
func (c *Client) CreateWatcher(ctx context.Context, url string) (string, error) {
|
||||
body, err := json.Marshal(CreateWatchRequest{
|
||||
URL: url,
|
||||
FetchBackend: _fetchBackendWebDriver,
|
||||
TimeBetweenCheck: &TimeBetweenCheck{
|
||||
Minutes: 3,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/watch", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("x-api-key", c.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return "", c.parseErrorResponse(resp)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return result.UUID, nil
|
||||
}
|
||||
|
||||
// UpdateWatcher updates an existing watch.
|
||||
func (c *Client) UpdateWatcher(ctx context.Context, uuid, url string) error {
|
||||
body, err := json.Marshal(UpdateWatchRequest{
|
||||
URL: url,
|
||||
FetchBackend: _fetchBackendWebDriver,
|
||||
TimeBetweenCheck: &TimeBetweenCheck{
|
||||
Minutes: 3,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.baseURL+"/api/v1/watch/"+uuid, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("x-api-key", c.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return c.parseErrorResponse(resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteWatcher deletes the watch with the given UUID.
|
||||
func (c *Client) DeleteWatcher(ctx context.Context, uuid string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL+"/api/v1/watch/"+uuid, http.NoBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("x-api-key", c.apiKey)
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound {
|
||||
return c.parseErrorResponse(resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseErrorResponse parses an error response from the API.
|
||||
func (c *Client) parseErrorResponse(resp *http.Response) error {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read error response: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("api error: %s", string(body))
|
||||
}
|
||||
72
server/core/internal/apps/webchanges/watch.go
Normal file
72
server/core/internal/apps/webchanges/watch.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package webchanges
|
||||
|
||||
import "time"
|
||||
|
||||
// _fetchBackendWebDriver defines the fetch backend constant for HTML WebDriver.
|
||||
const _fetchBackendWebDriver = "html_webdriver"
|
||||
|
||||
// Watch represents a changedetection.io watch configuration.
|
||||
type Watch struct {
|
||||
// URL is the URL to be monitored.
|
||||
URL string
|
||||
|
||||
// Unreachable indicates whether the URL is currently unreachable.
|
||||
Unreachable bool
|
||||
|
||||
// LastChangedAt holds the timestamp of the last change detected.
|
||||
LastChangedAt time.Time
|
||||
}
|
||||
|
||||
// rawWatch represents a changedetection.io watch configuration.
|
||||
type rawWatch struct {
|
||||
// UUID is the unique identifier of the watch.
|
||||
UUID string `json:"uuid"`
|
||||
|
||||
// URL is the URL to be monitored.
|
||||
URL string `json:"url"`
|
||||
|
||||
// TimeBetweenCheck specifies the interval between checks.
|
||||
TimeBetweenCheck TimeBetweenCheck `json:"time_between_check"`
|
||||
|
||||
// LastChanged holds the timestamp of the last change detected.
|
||||
LastChanged int64 `json:"last_changed"`
|
||||
|
||||
// LastError holds the last error message encountered during checks.
|
||||
LastError any `json:"last_error"`
|
||||
}
|
||||
|
||||
// TimeBetweenCheck represents the interval between watch checks.
|
||||
type TimeBetweenCheck struct {
|
||||
// Minutes represents the number of minutes.
|
||||
Minutes int `json:"minutes"`
|
||||
}
|
||||
|
||||
// CreateWatchRequest represents the request body for creating a new watch.
|
||||
type CreateWatchRequest struct {
|
||||
// URL is the URL to be monitored.
|
||||
URL string `json:"url"`
|
||||
|
||||
// TimeBetweenCheck specifies the interval between checks.
|
||||
TimeBetweenCheck *TimeBetweenCheck `json:"time_between_check"`
|
||||
|
||||
// FetchBackend specifies the fetch backend to be used.
|
||||
FetchBackend string `json:"fetch_backend"`
|
||||
|
||||
// TimeBetweenCheckUseDefault indicates if the default interval is used.
|
||||
TimeBetweenCheckUseDefault bool `json:"time_between_check_use_default"`
|
||||
}
|
||||
|
||||
// UpdateWatchRequest represents the request body for updating a watch.
|
||||
type UpdateWatchRequest struct {
|
||||
// URL is the URL to be monitored.
|
||||
URL string `json:"url"`
|
||||
|
||||
// TimeBetweenCheck specifies the interval between checks.
|
||||
TimeBetweenCheck *TimeBetweenCheck `json:"time_between_check"`
|
||||
|
||||
// FetchBackend specifies the fetch backend to be used.
|
||||
FetchBackend string `json:"fetch_backend"`
|
||||
|
||||
// TimeBetweenCheckUseDefault indicates if the default interval is used.
|
||||
TimeBetweenCheckUseDefault bool `json:"time_between_check_use_default"`
|
||||
}
|
||||
242
server/core/internal/assistant/assistant.go
Normal file
242
server/core/internal/assistant/assistant.go
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
// Package assistant runs the AI chat that lets the user interact
|
||||
// with documents in their organisation. The Manager holds shared
|
||||
// dependencies (Anthropic client, DB, edit-RPC client, Redis
|
||||
// session store, metrics); each connected user gets a Session that
|
||||
// owns the per-turn agent loop.
|
||||
package assistant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
anthropic "github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/anthropics/anthropic-sdk-go/option"
|
||||
"github.com/gomodule/redigo/redis"
|
||||
"github.com/jellydator/xync"
|
||||
"github.com/oxynote/heimdall/internal/assistant/protocol"
|
||||
"github.com/oxynote/heimdall/internal/assistant/tools"
|
||||
"github.com/oxynote/heimdall/internal/document/liveedit"
|
||||
"github.com/oxynote/purse/redkit"
|
||||
"github.com/oxynote/purse/util/errutil"
|
||||
"github.com/oxynote/purse/util/metricutil"
|
||||
)
|
||||
|
||||
// _sessionExpiration is how long Redis retains the conversation
|
||||
// history for an idle session. Sessions resume from this history
|
||||
// on reconnect.
|
||||
const _sessionExpiration = time.Hour * 24
|
||||
|
||||
// Manager holds dependencies shared across assistant sessions.
|
||||
type Manager struct {
|
||||
log *slog.Logger
|
||||
db tools.DB
|
||||
search tools.Searcher
|
||||
pool *redkit.ValueStore[[]anthropic.MessageParam]
|
||||
client *anthropic.Client
|
||||
liveedit *liveedit.Client
|
||||
tree tools.TreeNotifier
|
||||
metrics *metrics
|
||||
}
|
||||
|
||||
// NewManager constructs an assistant Manager. The liveEditClient is
|
||||
// the live-edit pipe to the Node hocuspocus service; the search
|
||||
// client backs the search_documents tool; the tree notifier
|
||||
// broadcasts sidebar refresh events after assistant-driven document
|
||||
// tree mutations and is wired post-construction via SetTreeNotifier
|
||||
// because the dochandler that satisfies it is built later, inside
|
||||
// server.NewServer.
|
||||
func NewManager(
|
||||
log *slog.Logger,
|
||||
db tools.DB,
|
||||
pool *redis.Pool,
|
||||
apiKey string,
|
||||
fc metricutil.Factory,
|
||||
liveEditClient *liveedit.Client,
|
||||
search tools.Searcher,
|
||||
) *Manager {
|
||||
client := anthropic.NewClient(option.WithAPIKey(apiKey))
|
||||
|
||||
return &Manager{
|
||||
log: log.With("component", "assistant"),
|
||||
db: db,
|
||||
search: search,
|
||||
pool: redkit.NewValueStore[[]anthropic.MessageParam](pool, _sessionExpiration),
|
||||
client: &client,
|
||||
liveedit: liveEditClient,
|
||||
metrics: newMetrics(fc),
|
||||
}
|
||||
}
|
||||
|
||||
// SetTreeNotifier wires the tree-change notifier the assistant uses
|
||||
// to broadcast sidebar refresh events after document tree mutations.
|
||||
// Call this once during startup, after the dochandler that satisfies
|
||||
// tools.TreeNotifier is constructed. The setter is not safe for
|
||||
// concurrent use with NewSession; wire it before serving traffic.
|
||||
func (m *Manager) SetTreeNotifier(tree tools.TreeNotifier) {
|
||||
m.tree = tree
|
||||
}
|
||||
|
||||
// NewSession creates a new assistant session bound to the given
|
||||
// organisation and user. The session starts without an active
|
||||
// document; the client sends a set_active_document message right
|
||||
// after connect to tell the model which document is in view.
|
||||
func (m *Manager) NewSession(
|
||||
ctx context.Context,
|
||||
orgID, userID string,
|
||||
writer protocol.SessionWriter,
|
||||
) (*Session, error) {
|
||||
msg, err := m.pool.Get(ctx, createSessionKey(orgID, userID))
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
// OK.
|
||||
case errors.Is(err, errutil.ErrNotFound):
|
||||
msg = &[]anthropic.MessageParam{}
|
||||
default:
|
||||
return nil, fmt.Errorf("failed to get session messages: %w", err)
|
||||
}
|
||||
|
||||
session := &Session{
|
||||
man: m,
|
||||
orgID: orgID,
|
||||
userID: userID,
|
||||
writer: writer,
|
||||
supv: xync.NewSupervisor(),
|
||||
messages: *msg,
|
||||
tools: tools.NewManager(m.log, m.db, m.search, m.liveedit, m.tree, orgID, userID),
|
||||
}
|
||||
|
||||
session.sendHistory(ctx)
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// saveMessages persists the current session messages to Redis.
|
||||
func (m *Manager) saveMessages(ctx context.Context, orgID, userID string, messages []anthropic.MessageParam) {
|
||||
err := m.pool.Set(ctx, createSessionKey(orgID, userID), messages)
|
||||
if err != nil {
|
||||
m.log.Error("failed to save session messages",
|
||||
slog.String("error", err.Error()),
|
||||
slog.String("org_id", orgID),
|
||||
slog.String("user_id", userID),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteMessages removes the session messages from Redis, called
|
||||
// when the user issues a reset.
|
||||
func (m *Manager) deleteMessages(ctx context.Context, orgID, userID string) {
|
||||
err := m.pool.Delete(ctx, createSessionKey(orgID, userID))
|
||||
if err != nil {
|
||||
m.log.Error("failed to delete session messages",
|
||||
slog.String("error", err.Error()),
|
||||
slog.String("org_id", orgID),
|
||||
slog.String("user_id", userID),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// stripToolMessages returns a copy of the messages with tool
|
||||
// interactions removed. Assistant messages have tool_use blocks
|
||||
// stripped (keeping only text); user messages that contain only
|
||||
// tool_result blocks are dropped entirely. This keeps history
|
||||
// small and avoids serialising stale tool round-trips back to
|
||||
// Anthropic on the next user turn.
|
||||
func stripToolMessages(messages []anthropic.MessageParam) []anthropic.MessageParam {
|
||||
cleaned := make([]anthropic.MessageParam, 0, len(messages))
|
||||
|
||||
for _, msg := range messages {
|
||||
switch msg.Role {
|
||||
case anthropic.MessageParamRoleAssistant:
|
||||
var textBlocks []anthropic.ContentBlockParamUnion
|
||||
|
||||
for _, block := range msg.Content {
|
||||
if block.OfText != nil {
|
||||
textBlocks = append(textBlocks, block)
|
||||
}
|
||||
}
|
||||
|
||||
if len(textBlocks) > 0 {
|
||||
cleaned = append(cleaned, anthropic.MessageParam{
|
||||
Role: msg.Role,
|
||||
Content: textBlocks,
|
||||
})
|
||||
}
|
||||
|
||||
case anthropic.MessageParamRoleUser:
|
||||
hasText := false
|
||||
|
||||
for _, block := range msg.Content {
|
||||
if block.OfText != nil {
|
||||
hasText = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasText {
|
||||
cleaned = append(cleaned, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// createSessionKey builds the Redis key for an organisation/user
|
||||
// pair's conversation history.
|
||||
func createSessionKey(orgID, userID string) string {
|
||||
return fmt.Sprintf("assistant:session:%s:%s", orgID, userID)
|
||||
}
|
||||
|
||||
// buildHistory converts Anthropic message params into protocol
|
||||
// history entries by extracting text content from user and
|
||||
// assistant messages. Tool interactions are excluded. Assistant
|
||||
// messages that also contain a tool_use block are intermediate
|
||||
// narration ("Let me look at the document…") and are skipped —
|
||||
// only the final assistant reply is restored into the chat.
|
||||
func buildHistory(messages []anthropic.MessageParam) []protocol.HistoryEntry {
|
||||
entries := make([]protocol.HistoryEntry, 0, len(messages))
|
||||
|
||||
for _, msg := range messages {
|
||||
if msg.Role == anthropic.MessageParamRoleAssistant && hasToolUse(msg) {
|
||||
continue
|
||||
}
|
||||
|
||||
var text strings.Builder
|
||||
|
||||
for _, block := range msg.Content {
|
||||
if block.OfText != nil {
|
||||
text.WriteString(block.OfText.Text)
|
||||
}
|
||||
}
|
||||
|
||||
content := text.String()
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
entries = append(entries, protocol.HistoryEntry{
|
||||
Role: string(msg.Role),
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// hasToolUse reports whether the assistant message carries at
|
||||
// least one tool_use block — used to identify intermediate
|
||||
// narration that shouldn't surface in the restored chat history.
|
||||
func hasToolUse(msg anthropic.MessageParam) bool {
|
||||
for _, block := range msg.Content {
|
||||
if block.OfToolUse != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
85
server/core/internal/assistant/metric.go
Normal file
85
server/core/internal/assistant/metric.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"github.com/oxynote/heimdall/internal/assistant/tools"
|
||||
"github.com/oxynote/purse/util/metricutil"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
const (
|
||||
_subsystemAssistant = "assistant"
|
||||
_toolLabel = "tool"
|
||||
_statusLabel = "status"
|
||||
_tokenTypeLabel = "type"
|
||||
)
|
||||
|
||||
// metrics holds Prometheus metrics for AI assistant operations.
|
||||
type metrics struct {
|
||||
toolCalls metricutil.CounterVec
|
||||
toolDuration metricutil.HistogramVec
|
||||
tokenUsage metricutil.CounterVec
|
||||
}
|
||||
|
||||
// newMetrics creates assistant metrics from the given factory.
|
||||
func newMetrics(fc metricutil.Factory) *metrics {
|
||||
return &metrics{
|
||||
toolCalls: fc.NewCounterVec(
|
||||
metricutil.Options{
|
||||
Subsystem: _subsystemAssistant,
|
||||
Name: "tool_calls_total",
|
||||
Help: "Tracks the number of AI assistant tool calls.",
|
||||
},
|
||||
[]string{_toolLabel, _statusLabel},
|
||||
),
|
||||
toolDuration: fc.NewHistogramVec(
|
||||
metricutil.Options{
|
||||
Subsystem: _subsystemAssistant,
|
||||
Name: "tool_call_duration_seconds",
|
||||
Help: "Tracks the duration of AI assistant tool calls.",
|
||||
},
|
||||
[]string{_toolLabel},
|
||||
),
|
||||
tokenUsage: fc.NewCounterVec(
|
||||
metricutil.Options{
|
||||
Subsystem: _subsystemAssistant,
|
||||
Name: "token_usage_total",
|
||||
Help: "Tracks the number of tokens used by AI assistant.",
|
||||
},
|
||||
[]string{_tokenTypeLabel},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// recordToolCall records a tool call metric with the given tool name and status.
|
||||
func (m *metrics) recordToolCall(tool tools.Name, status string) {
|
||||
m.toolCalls.With(prometheus.Labels{
|
||||
_toolLabel: string(tool),
|
||||
_statusLabel: status,
|
||||
}).Inc()
|
||||
}
|
||||
|
||||
// recordToolDuration records the duration of a tool call.
|
||||
func (m *metrics) recordToolDuration(tool tools.Name, seconds float64) {
|
||||
m.toolDuration.With(prometheus.Labels{
|
||||
_toolLabel: string(tool),
|
||||
}).Observe(seconds)
|
||||
}
|
||||
|
||||
// recordTokenUsage records token usage from an Anthropic API response.
|
||||
func (m *metrics) recordTokenUsage(inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens int64) {
|
||||
m.tokenUsage.With(prometheus.Labels{_tokenTypeLabel: "input"}).
|
||||
Add(float64(inputTokens))
|
||||
|
||||
m.tokenUsage.With(prometheus.Labels{_tokenTypeLabel: "output"}).
|
||||
Add(float64(outputTokens))
|
||||
|
||||
if cacheCreationTokens > 0 {
|
||||
m.tokenUsage.With(prometheus.Labels{_tokenTypeLabel: "cache_creation"}).
|
||||
Add(float64(cacheCreationTokens))
|
||||
}
|
||||
|
||||
if cacheReadTokens > 0 {
|
||||
m.tokenUsage.With(prometheus.Labels{_tokenTypeLabel: "cache_read"}).
|
||||
Add(float64(cacheReadTokens))
|
||||
}
|
||||
}
|
||||
145
server/core/internal/assistant/prompt.go
Normal file
145
server/core/internal/assistant/prompt.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// _basePrompt is the system prompt sent to Anthropic on every
|
||||
// assistant turn. The prompt establishes the Rubber Duck persona,
|
||||
// inlines the full canonical block schema (so the model has the
|
||||
// per-type rules without needing a discovery tool), enumerates the
|
||||
// minimal-markdown subset used for inline text, and codifies the
|
||||
// edit etiquette and aesthetics rules.
|
||||
const _basePrompt = `You are Rubber Duck, an AI assistant inside Oxynote, a collaborative product for writing technical documentation. The documents you read and write capture project information, architecture, and feature descriptions. They are written for humans first, balancing prose with technical detail where it sharpens meaning. Your job is helping people think through and write those documents, not writing code.
|
||||
|
||||
Be terse and iterative. Skip preamble. Don't use em dashes. Make your answers concise; a few sentences max; list things when listing is appropriate.
|
||||
|
||||
As a rubber duck, your job is to:
|
||||
- Point out gaps, ambiguities, and unstated assumptions in the document
|
||||
- Suggest features, edge cases, or scenarios the document doesn't cover
|
||||
- Ask clarifying questions when intent is unclear
|
||||
- Help the writer think through how something could work, without making decisions for them
|
||||
|
||||
## Tool use
|
||||
|
||||
You have tools for reading and writing documents in the user's organisation. Read tools execute immediately. Write tools require user confirmation; group related edits into the same turn so they share one confirmation.
|
||||
|
||||
Read first, then edit. Use search_documents to find candidates across the org. Use read_document_summary as your default way to look inside a document. It returns per-block uids, kinds, and flattened text. Reach for read_block only when you actually need the inner structure of a specific block (e.g. before replacing a split_doc_param_list).
|
||||
|
||||
## Canonical block model
|
||||
|
||||
You read and write blocks in the canonical model. The editor's TipTap schema is hidden behind it. Every block has a "type" plus a per-type set of fields. uid is auto-generated when you don't set it; supply it on edits to refer back to a specific block.
|
||||
|
||||
Inline text uses a minimal-markdown subset:
|
||||
- **bold**, *italic*, _underline_, ~~strike~~, backtick code
|
||||
- [label](url) for links
|
||||
- Backslash-escape literals (\*, \_, \~, backtick, \[, \\)
|
||||
|
||||
Multi-paragraph content is multiple blocks, not one block with newlines. Inside code, titled_code, and mermaid blocks, text is raw; markdown is not parsed.
|
||||
|
||||
### Block types
|
||||
|
||||
| type | content fields | attrs |
|
||||
|---|---|---|
|
||||
| paragraph | text | - |
|
||||
| heading | text | level (1, 2, or 3), required |
|
||||
| blockquote | text (single paragraph) | - |
|
||||
| bullet_list | items: [Block] | - |
|
||||
| ordered_list | items: [Block] | - |
|
||||
| task_list | task_items: [{checked, block}] | - |
|
||||
| callout | text (shorthand) **or** items: [Block] | icon (defaults to lucide:text) |
|
||||
| code | text (raw) | language (optional, default empty) |
|
||||
| titled_code | text (raw code body) | title (required), language (optional) |
|
||||
| mermaid | text (raw mermaid source) | - |
|
||||
| horizontal_rule | - | - |
|
||||
| image | - | src (required), alt, title, width |
|
||||
| figma | - | src (required), width, height |
|
||||
| metric | - | opaque metric configuration (do not author from scratch) |
|
||||
| metric_grid | items: [metric] | - |
|
||||
| split_doc | left: [Block], right: [Block] | inversed (optional) |
|
||||
| split_doc_param_list | header (plain text), params: [{name, type, description}] | - |
|
||||
|
||||
### Compound blocks
|
||||
|
||||
split_doc and split_doc_param_list are macros: express them with simple fields and the server expands them into the full nested editor structure.
|
||||
|
||||
A split_doc presents concept and example side by side. left must start with a heading at level 1; the panel provides its own visual emphasis, so don't drop the heading to a smaller level to match the surrounding outline. The rest may be paragraphs, lists, callouts, and optionally split_doc_param_lists at the end. right may only contain titled_code or metric blocks.
|
||||
|
||||
Example:
|
||||
{
|
||||
"type": "split_doc",
|
||||
"left": [
|
||||
{"type": "heading", "text": "POST /api/auth/login", "attrs": {"level": 1}},
|
||||
{"type": "paragraph", "text": "Issues a session JWT."}
|
||||
],
|
||||
"right": [
|
||||
{"type": "titled_code", "attrs": {"title": "Request"}, "text": "POST /api/auth/login\n{\n \"email\": \"...\",\n \"password\": \"...\"\n}"}
|
||||
]
|
||||
}
|
||||
|
||||
A split_doc_param_list captures a named, typed parameter table: request bodies, function signatures, config keys.
|
||||
|
||||
Example:
|
||||
{
|
||||
"type": "split_doc_param_list",
|
||||
"header": "Request body",
|
||||
"params": [
|
||||
{"name": "email", "type": "string", "description": "User email"},
|
||||
{"name": "totp", "type": "string?", "description": "Required when 2FA is enabled"}
|
||||
]
|
||||
}
|
||||
|
||||
### Block placement
|
||||
|
||||
Some types are macro internals and only legal inside their container:
|
||||
- titled_code and metric must live inside split_doc.right (metric also works inside metric_grid)
|
||||
- split_doc_param_list must live inside split_doc.left
|
||||
|
||||
Everything else (paragraph, heading, blockquote, lists, callout, code, mermaid, horizontal_rule, image, figma, metric_grid, split_doc) is fine at the document root.
|
||||
|
||||
## Edit etiquette
|
||||
|
||||
Prefer small, atomic edits. Each turn, fire as many tool calls as you need; they batch together. Specifically:
|
||||
|
||||
- For text-only changes, use update_block_text. It preserves the block's type and attrs.
|
||||
- For attribute-only changes (heading level, callout icon, image src), use update_block_attrs. It preserves the block's content.
|
||||
- Use replace_block only when both content and structure change.
|
||||
- When inserting a new block, do not set uid; the server generates one.
|
||||
- When editing an existing block, leave its uid alone (it's the handle you'll use next turn).
|
||||
|
||||
Finish what you start before moving on. A document, a section, a heading: complete the current one before starting the next. Don't scaffold structure you'll fill in later; if you can't fill it now, don't add it. A heading without content under it is worse than no heading.
|
||||
|
||||
Before declaring a task done, re-read what you wrote and check it against itself. Documents that contradict themselves are the worst failure mode; fix any inconsistency you find, then report.
|
||||
|
||||
## Aesthetics
|
||||
|
||||
Documents are for humans to read. Make them read well.
|
||||
|
||||
- Don't open a document with a heading that duplicates the document name.
|
||||
- Avoid horizontal_rule adjacent to split_doc; split_doc has its own visual dividers.
|
||||
- For code and titled_code: prefer language-agnostic pseudo-code or natural-language sketches unless the document specifically names a stack. Leave language empty by default rather than guessing "python" or "javascript".
|
||||
- Use split_doc when there's a concept that benefits from an inline example (endpoint + request body, behaviour + diagram).
|
||||
- Use split_doc_param_list for typed, named shapes (request fields, config keys, function arguments).
|
||||
- Use callout for important constraints, warnings, or gotchas, not for filler emphasis.
|
||||
- Don't over-heading: most documents need 2-3 sections, not 8.
|
||||
|
||||
`
|
||||
|
||||
// buildSystemPrompt assembles the prompt sent on each turn. The
|
||||
// activeDocumentID, when non-empty, hints to the model which
|
||||
// document the user is currently viewing, useful for resolving
|
||||
// "this document" or "here" references without forcing the user to
|
||||
// spell out an id.
|
||||
func buildSystemPrompt(activeDocumentID string) string {
|
||||
if activeDocumentID == "" {
|
||||
return _basePrompt
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(_basePrompt)
|
||||
sb.WriteString("\n## Current context\n\n")
|
||||
sb.WriteString(fmt.Sprintf("The user is currently viewing document `%s`. When they say \"this document\", \"here\", or \"the doc\" without naming one, this is the document they mean.\n", activeDocumentID))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
287
server/core/internal/assistant/protocol/protocol.go
Normal file
287
server/core/internal/assistant/protocol/protocol.go
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
// Package protocol describes the WebSocket message envelope used
|
||||
// between the AI chat client and the assistant session running on
|
||||
// the server.
|
||||
//
|
||||
// Tools execute server-side: the protocol carries text deltas,
|
||||
// completion signals, conversation history, errors, and a
|
||||
// confirmation handshake the client uses to approve or decline
|
||||
// batched write operations. The full set of tools and their inputs
|
||||
// lives in package tools — this file only describes the wire
|
||||
// envelope.
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// ServerMessageType identifies a message sent from the server to
|
||||
// the client.
|
||||
type ServerMessageType string
|
||||
|
||||
const (
|
||||
// ServerTypeTextDelta carries a fragment of the streaming
|
||||
// assistant response.
|
||||
ServerTypeTextDelta ServerMessageType = "text_delta"
|
||||
|
||||
// ServerTypeDone signals that the assistant finished its turn
|
||||
// (either reached a natural stop or hit the maximum agent
|
||||
// turn count).
|
||||
ServerTypeDone ServerMessageType = "done"
|
||||
|
||||
// ServerTypeError carries a one-line error message intended
|
||||
// for surfacing in the chat UI.
|
||||
ServerTypeError ServerMessageType = "error"
|
||||
|
||||
// ServerTypeHistory restores prior conversation entries when
|
||||
// the client first connects.
|
||||
ServerTypeHistory ServerMessageType = "history"
|
||||
|
||||
// ServerTypeConfirmRequest asks the user to approve or decline
|
||||
// a batch of pending write operations before the assistant
|
||||
// applies them.
|
||||
ServerTypeConfirmRequest ServerMessageType = "confirm_request"
|
||||
|
||||
// ServerTypeTextEnd signals that the assistant has finished
|
||||
// streaming one run of text. Kind tells the client whether the
|
||||
// text was intermediate narration (status — render as a
|
||||
// transient pill, don't persist in chat history) or the final
|
||||
// response (message — push into the chat as a real bubble).
|
||||
ServerTypeTextEnd ServerMessageType = "text_end"
|
||||
|
||||
// ServerTypeToolStatus carries a short, user-facing label
|
||||
// describing a tool the assistant is about to run (e.g.
|
||||
// "Reading 'Cat Facts'"). Surfaced as a transient pill so the
|
||||
// user can see what the assistant is doing while a turn
|
||||
// resolves through multiple tool calls.
|
||||
ServerTypeToolStatus ServerMessageType = "tool_status"
|
||||
)
|
||||
|
||||
// TextEndKind discriminates between intermediate narration and a
|
||||
// final assistant response on a text_end message.
|
||||
type TextEndKind string
|
||||
|
||||
const (
|
||||
// TextEndKindStatus means the just-streamed text was the model
|
||||
// thinking out loud before invoking tools — show as a transient
|
||||
// status indicator and discard once superseded.
|
||||
TextEndKindStatus TextEndKind = "status"
|
||||
|
||||
// TextEndKindMessage means the just-streamed text is the
|
||||
// assistant's final answer for this turn — keep it in the chat.
|
||||
TextEndKindMessage TextEndKind = "message"
|
||||
)
|
||||
|
||||
// ClientMessageType identifies a message sent from the client to
|
||||
// the server.
|
||||
type ClientMessageType string
|
||||
|
||||
const (
|
||||
// ClientTypeMessage is a new user prompt for the assistant.
|
||||
ClientTypeMessage ClientMessageType = "message"
|
||||
|
||||
// ClientTypeReset asks the server to drop the conversation
|
||||
// history for the active session.
|
||||
ClientTypeReset ClientMessageType = "reset"
|
||||
|
||||
// ClientTypeConfirmResponse carries the user's approval (or
|
||||
// decline) of a previously-sent ConfirmRequest.
|
||||
ClientTypeConfirmResponse ClientMessageType = "confirm_response"
|
||||
|
||||
// ClientTypeSetActiveDocument tells the session which document
|
||||
// the user is currently viewing so the system prompt can resolve
|
||||
// "this document" without an explicit id. Sent on connect and
|
||||
// again whenever the user navigates between documents — the
|
||||
// connection itself persists across doc switches so an in-flight
|
||||
// turn isn't torn down.
|
||||
ClientTypeSetActiveDocument ClientMessageType = "set_active_document"
|
||||
)
|
||||
|
||||
// TextDeltaMessage carries one fragment of the streaming assistant
|
||||
// response.
|
||||
type TextDeltaMessage struct {
|
||||
// Type is always ServerTypeTextDelta.
|
||||
Type ServerMessageType `json:"type"`
|
||||
|
||||
// Content is the text fragment.
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// NewTextDeltaMessage builds a TextDeltaMessage.
|
||||
func NewTextDeltaMessage(content string) TextDeltaMessage {
|
||||
return TextDeltaMessage{Type: ServerTypeTextDelta, Content: content}
|
||||
}
|
||||
|
||||
// TextEndMessage marks the end of a text content block streamed
|
||||
// via text_delta. Kind distinguishes intermediate narration from
|
||||
// the assistant's final answer.
|
||||
type TextEndMessage struct {
|
||||
// Type is always ServerTypeTextEnd.
|
||||
Type ServerMessageType `json:"type"`
|
||||
|
||||
// Kind is one of TextEndKindStatus or TextEndKindMessage.
|
||||
Kind TextEndKind `json:"kind"`
|
||||
}
|
||||
|
||||
// NewTextEndMessage builds a TextEndMessage.
|
||||
func NewTextEndMessage(kind TextEndKind) TextEndMessage {
|
||||
return TextEndMessage{Type: ServerTypeTextEnd, Kind: kind}
|
||||
}
|
||||
|
||||
// ToolStatusMessage carries a short label describing a tool the
|
||||
// assistant is about to run. Tool is the raw tool name (same set
|
||||
// as ConfirmAction.Tool) — the client maps it to a color category
|
||||
// to keep the pill consistent with the confirm UI.
|
||||
type ToolStatusMessage struct {
|
||||
// Type is always ServerTypeToolStatus.
|
||||
Type ServerMessageType `json:"type"`
|
||||
|
||||
// Tool is the tool name (e.g. "create_document", "read_block").
|
||||
Tool string `json:"tool"`
|
||||
|
||||
// Label is the user-facing description (e.g. "Reading 'Cat
|
||||
// Facts'", "Updating 'Auth spec'"). Starts with a verb the
|
||||
// client highlights in the pill.
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// NewToolStatusMessage builds a ToolStatusMessage.
|
||||
func NewToolStatusMessage(tool, label string) ToolStatusMessage {
|
||||
return ToolStatusMessage{Type: ServerTypeToolStatus, Tool: tool, Label: label}
|
||||
}
|
||||
|
||||
// DoneMessage signals the end of the assistant's turn.
|
||||
type DoneMessage struct {
|
||||
// Type is always ServerTypeDone.
|
||||
Type ServerMessageType `json:"type"`
|
||||
}
|
||||
|
||||
// NewDoneMessage builds a DoneMessage.
|
||||
func NewDoneMessage() DoneMessage {
|
||||
return DoneMessage{Type: ServerTypeDone}
|
||||
}
|
||||
|
||||
// ErrorMessage surfaces a failure to the client UI.
|
||||
type ErrorMessage struct {
|
||||
// Type is always ServerTypeError.
|
||||
Type ServerMessageType `json:"type"`
|
||||
|
||||
// Message is the one-line error description.
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// NewErrorMessage builds an ErrorMessage.
|
||||
func NewErrorMessage(message string) ErrorMessage {
|
||||
return ErrorMessage{Type: ServerTypeError, Message: message}
|
||||
}
|
||||
|
||||
// HistoryEntry is one prior assistant or user message recovered
|
||||
// when the client reconnects.
|
||||
type HistoryEntry struct {
|
||||
// Role is "user" or "assistant".
|
||||
Role string `json:"role"`
|
||||
|
||||
// Content is the message text. Tool interactions are not part
|
||||
// of the restored history.
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// HistoryMessage carries the conversation history sent to a freshly
|
||||
// connected client.
|
||||
type HistoryMessage struct {
|
||||
// Type is always ServerTypeHistory.
|
||||
Type ServerMessageType `json:"type"`
|
||||
|
||||
// Messages is the ordered conversation history.
|
||||
Messages []HistoryEntry `json:"messages"`
|
||||
}
|
||||
|
||||
// NewHistoryMessage builds a HistoryMessage from a list of entries.
|
||||
func NewHistoryMessage(messages []HistoryEntry) HistoryMessage {
|
||||
return HistoryMessage{Type: ServerTypeHistory, Messages: messages}
|
||||
}
|
||||
|
||||
// ConfirmRequest asks the user to approve or decline a batch of
|
||||
// pending write actions the assistant has proposed in the current
|
||||
// turn. The client must reply with a ConfirmResponse carrying the
|
||||
// same TurnID before the assistant applies anything.
|
||||
type ConfirmRequest struct {
|
||||
// Type is always ServerTypeConfirmRequest.
|
||||
Type ServerMessageType `json:"type"`
|
||||
|
||||
// TurnID identifies the pending confirm. The client echoes it
|
||||
// back in ConfirmResponse so the session matches the answer
|
||||
// to the right batch.
|
||||
TurnID string `json:"turn_id"`
|
||||
|
||||
// Actions describes the proposed writes for display in the
|
||||
// confirm UI.
|
||||
Actions []ConfirmAction `json:"actions"`
|
||||
}
|
||||
|
||||
// ConfirmAction summarises one write operation for the confirm UI.
|
||||
type ConfirmAction struct {
|
||||
// Tool is the name of the tool the assistant intends to call
|
||||
// (e.g. "insert_block", "delete_document"). The UI uses this
|
||||
// to pick an affordance (e.g. emphasise deletes).
|
||||
Tool string `json:"tool"`
|
||||
|
||||
// DocumentName is the human-friendly name of the document the
|
||||
// action targets, when known. Empty for ops that don't
|
||||
// reference an existing document (rare).
|
||||
DocumentName string `json:"document_name,omitempty"`
|
||||
|
||||
// DocumentID is the target document's id, for any UI that
|
||||
// wants to render a deep link.
|
||||
DocumentID string `json:"document_id,omitempty"`
|
||||
|
||||
// Summary is a one-sentence description of the proposed
|
||||
// change, e.g. "Insert callout after 'Rate limit' section".
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// NewConfirmRequest builds a ConfirmRequest.
|
||||
func NewConfirmRequest(turnID string, actions []ConfirmAction) ConfirmRequest {
|
||||
return ConfirmRequest{Type: ServerTypeConfirmRequest, TurnID: turnID, Actions: actions}
|
||||
}
|
||||
|
||||
// ConfirmResponse carries the user's approval decision for a
|
||||
// pending ConfirmRequest.
|
||||
type ConfirmResponse struct {
|
||||
// TurnID echoes the ConfirmRequest's TurnID so the session
|
||||
// matches the answer to the right batch.
|
||||
TurnID string `json:"turn_id"`
|
||||
|
||||
// Approved is true when the user accepted the batch, false
|
||||
// when they declined.
|
||||
Approved bool `json:"approved"`
|
||||
|
||||
// All, when set alongside Approved=true, also auto-approves
|
||||
// any further write batches in the current turn — the server
|
||||
// skips the prompt and proceeds directly. Resets at the next
|
||||
// user message.
|
||||
All bool `json:"all,omitempty"`
|
||||
}
|
||||
|
||||
// SessionWriter is the interface the assistant session uses to
|
||||
// stream messages back to the client. Implementations must be safe
|
||||
// for concurrent use because text deltas and confirm requests can
|
||||
// be produced from different goroutines.
|
||||
type SessionWriter interface {
|
||||
// WriteJSON serialises msg as JSON and sends it on the
|
||||
// underlying transport.
|
||||
WriteJSON(ctx context.Context, msg any)
|
||||
}
|
||||
|
||||
// ClientMessage is a generic envelope for messages received from
|
||||
// the client; the Type field discriminates the rest of the payload.
|
||||
// The session uses gjson to peek at the type before unmarshalling
|
||||
// into a concrete struct, but the type is kept here for direct
|
||||
// callers.
|
||||
type ClientMessage struct {
|
||||
// Type identifies the message.
|
||||
Type ClientMessageType `json:"type"`
|
||||
|
||||
// Raw carries the unparsed remainder of the message.
|
||||
Raw json.RawMessage `json:"-"`
|
||||
}
|
||||
774
server/core/internal/assistant/session.go
Normal file
774
server/core/internal/assistant/session.go
Normal file
|
|
@ -0,0 +1,774 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
anthropic "github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/anthropics/anthropic-sdk-go/packages/param"
|
||||
"github.com/jellydator/xync"
|
||||
"github.com/oxynote/heimdall/internal/assistant/protocol"
|
||||
"github.com/oxynote/heimdall/internal/assistant/tools"
|
||||
"github.com/oxynote/purse/util/timeutil"
|
||||
"github.com/rs/xid"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
// _maxAgentTurns caps the agent loop so a runaway model can't
|
||||
// burn budget indefinitely.
|
||||
_maxAgentTurns = 50
|
||||
|
||||
// _maxMessages caps the conversation history retained in
|
||||
// Redis. The trim is applied at the start of each user message.
|
||||
_maxMessages = 100
|
||||
|
||||
// _model is the Anthropic model used for chat turns.
|
||||
_model = anthropic.ModelClaudeOpus4_6
|
||||
|
||||
// _maxTokens caps the model's response length per turn.
|
||||
_maxTokens = 64000
|
||||
|
||||
// _maxToolResultBytes caps any single tool_result. Larger
|
||||
// results are truncated to a short error so the conversation
|
||||
// doesn't balloon.
|
||||
_maxToolResultBytes = 80000
|
||||
|
||||
// _confirmTimeout bounds how long the agent waits for a
|
||||
// confirm_response from the client. Treated as a decline on
|
||||
// expiry — the writes are skipped and the model receives
|
||||
// declined results.
|
||||
_confirmTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
// Session holds per-connection state for an AI assistant session.
|
||||
type Session struct {
|
||||
man *Manager
|
||||
orgID string
|
||||
userID string
|
||||
writer protocol.SessionWriter
|
||||
tools *tools.Manager
|
||||
|
||||
// activeDocumentID is the document the user is currently
|
||||
// viewing, surfaced into the system prompt so the model can
|
||||
// resolve "this document" without asking.
|
||||
activeDocumentID string
|
||||
|
||||
supv *xync.Supervisor
|
||||
messages []anthropic.MessageParam
|
||||
|
||||
mu sync.Mutex
|
||||
processing bool
|
||||
|
||||
// confirmMu guards the per-turn confirmation channel. Only one
|
||||
// confirm can be in flight at a time because the agent loop
|
||||
// blocks until it receives the response (or the timeout fires).
|
||||
confirmMu sync.Mutex
|
||||
confirmCh chan bool
|
||||
confirmID string
|
||||
|
||||
// autoApproveTurn skips the confirm prompt for the remainder
|
||||
// of the current turn — set when the client responds to a
|
||||
// confirm with All=true. Reset at the start of every new user
|
||||
// message so a fresh turn always re-prompts on the first
|
||||
// write batch. Guarded by mu.
|
||||
autoApproveTurn bool
|
||||
}
|
||||
|
||||
// Close stops and closes all processes for the session.
|
||||
func (s *Session) Close() error {
|
||||
s.supv.CloseAndWait()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetActiveDocument records which document the user is viewing.
|
||||
// Called by the chat handler from the WebSocket upgrade path or
|
||||
// from a "document changed" client message.
|
||||
func (s *Session) SetActiveDocument(documentID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.activeDocumentID = documentID
|
||||
}
|
||||
|
||||
// sendHistory sends the current message history to the client so it
|
||||
// can restore previous conversation state.
|
||||
func (s *Session) sendHistory(ctx context.Context) {
|
||||
entries := buildHistory(s.messages)
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
s.writer.WriteJSON(ctx, protocol.NewHistoryMessage(entries))
|
||||
}
|
||||
|
||||
// Process handles incoming messages from the client. It dispatches
|
||||
// by message type to the appropriate handler.
|
||||
func (s *Session) Process(ctx context.Context, msg []byte) {
|
||||
tp := gjson.GetBytes(msg, "type").String()
|
||||
|
||||
switch protocol.ClientMessageType(tp) {
|
||||
case protocol.ClientTypeMessage:
|
||||
s.mu.Lock()
|
||||
if s.processing {
|
||||
s.mu.Unlock()
|
||||
s.writer.WriteJSON(ctx, protocol.NewErrorMessage("already processing a message"))
|
||||
|
||||
return
|
||||
}
|
||||
s.processing = true
|
||||
s.mu.Unlock()
|
||||
|
||||
s.supv.Go(func(_ context.Context) {
|
||||
s.handleUserMessage(
|
||||
ctx,
|
||||
gjson.GetBytes(msg, "content").String(),
|
||||
)
|
||||
|
||||
s.mu.Lock()
|
||||
s.processing = false
|
||||
s.mu.Unlock()
|
||||
})
|
||||
|
||||
case protocol.ClientTypeReset:
|
||||
s.mu.Lock()
|
||||
if s.processing {
|
||||
s.mu.Unlock()
|
||||
s.writer.WriteJSON(ctx, protocol.NewErrorMessage("cannot reset while processing"))
|
||||
|
||||
return
|
||||
}
|
||||
s.messages = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
s.man.deleteMessages(ctx, s.orgID, s.userID)
|
||||
|
||||
case protocol.ClientTypeConfirmResponse:
|
||||
s.deliverConfirmResponse(
|
||||
gjson.GetBytes(msg, "turn_id").String(),
|
||||
gjson.GetBytes(msg, "approved").Bool(),
|
||||
gjson.GetBytes(msg, "all").Bool(),
|
||||
)
|
||||
|
||||
case protocol.ClientTypeSetActiveDocument:
|
||||
s.SetActiveDocument(gjson.GetBytes(msg, "document_id").String())
|
||||
|
||||
default:
|
||||
s.writer.WriteJSON(ctx, protocol.NewErrorMessage("unknown message type"))
|
||||
}
|
||||
}
|
||||
|
||||
// handleUserMessage runs the agent loop for a single user message.
|
||||
func (s *Session) handleUserMessage(ctx context.Context, content string) {
|
||||
s.trimMessages()
|
||||
|
||||
checkpoint := len(s.messages)
|
||||
|
||||
s.messages = append(s.messages, anthropic.NewUserMessage(
|
||||
anthropic.NewTextBlock(content),
|
||||
))
|
||||
|
||||
// Snapshot the active document so a mid-turn navigation by the
|
||||
// user doesn't shift the system prompt under the in-flight turn.
|
||||
// Also reset auto-approve so a fresh turn always re-prompts.
|
||||
s.mu.Lock()
|
||||
activeDocumentID := s.activeDocumentID
|
||||
s.autoApproveTurn = false
|
||||
s.mu.Unlock()
|
||||
|
||||
for turn := range _maxAgentTurns {
|
||||
stopReason, err := s.callAnthropic(ctx, activeDocumentID)
|
||||
if err != nil {
|
||||
s.man.log.Error(
|
||||
"anthropic call failed",
|
||||
slog.String("error", err.Error()),
|
||||
slog.Int("turn", turn),
|
||||
)
|
||||
|
||||
// Roll back to the checkpoint so the next turn
|
||||
// doesn't inherit a partial tool dialogue.
|
||||
s.messages = s.messages[:checkpoint]
|
||||
|
||||
s.writer.WriteJSON(ctx, protocol.NewErrorMessage("failed to execute AI request"))
|
||||
s.writer.WriteJSON(ctx, protocol.NewDoneMessage())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if stopReason != anthropic.StopReasonToolUse {
|
||||
s.messages = stripToolMessages(s.messages)
|
||||
s.man.saveMessages(ctx, s.orgID, s.userID, s.messages)
|
||||
s.writer.WriteJSON(ctx, protocol.NewDoneMessage())
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.messages = stripToolMessages(s.messages)
|
||||
s.man.saveMessages(ctx, s.orgID, s.userID, s.messages)
|
||||
s.writer.WriteJSON(ctx, protocol.NewErrorMessage("maximum agent turns reached"))
|
||||
s.writer.WriteJSON(ctx, protocol.NewDoneMessage())
|
||||
}
|
||||
|
||||
// callAnthropic streams one Anthropic call, relays text deltas to
|
||||
// the client, collects any tool_use blocks, runs reads
|
||||
// concurrently, gates writes behind a confirm prompt, and feeds the
|
||||
// results back into the conversation. Returns the stop reason so
|
||||
// the outer loop knows whether another turn is needed.
|
||||
func (s *Session) callAnthropic(ctx context.Context, activeDocumentID string) (anthropic.StopReason, error) {
|
||||
stream := s.man.client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
|
||||
Model: _model,
|
||||
MaxTokens: _maxTokens,
|
||||
Temperature: param.Opt[float64]{
|
||||
Value: 0.3,
|
||||
},
|
||||
System: []anthropic.TextBlockParam{
|
||||
{
|
||||
Text: buildSystemPrompt(activeDocumentID),
|
||||
CacheControl: anthropic.CacheControlEphemeralParam{
|
||||
TTL: anthropic.CacheControlEphemeralTTLTTL1h,
|
||||
},
|
||||
},
|
||||
},
|
||||
Messages: s.messages,
|
||||
Tools: tools.AnthropicTools(),
|
||||
})
|
||||
defer stream.Close()
|
||||
|
||||
var (
|
||||
assistantBlocks []anthropic.ContentBlockParamUnion
|
||||
stopReason anthropic.StopReason
|
||||
|
||||
// Track text accumulation for conversation history.
|
||||
textAccum strings.Builder
|
||||
inText bool
|
||||
|
||||
// textStreamed is true once any text_delta has been sent
|
||||
// out — used to decide whether a closing text_end is owed
|
||||
// to the client at the end of the call.
|
||||
textStreamed bool
|
||||
|
||||
// Active tool_use being assembled from streaming deltas.
|
||||
activeTool struct {
|
||||
id string
|
||||
name string
|
||||
json strings.Builder
|
||||
}
|
||||
)
|
||||
|
||||
for stream.Next() {
|
||||
evt := stream.Current()
|
||||
|
||||
switch evt.Type {
|
||||
case "content_block_start":
|
||||
switch evt.ContentBlock.Type {
|
||||
case "text":
|
||||
inText = true
|
||||
textAccum.Reset()
|
||||
case "tool_use":
|
||||
activeTool.id = evt.ContentBlock.ID
|
||||
activeTool.name = evt.ContentBlock.Name
|
||||
activeTool.json.Reset()
|
||||
}
|
||||
|
||||
case "content_block_delta":
|
||||
switch evt.Delta.Type {
|
||||
case "text_delta":
|
||||
s.writer.WriteJSON(ctx, protocol.NewTextDeltaMessage(evt.Delta.Text))
|
||||
textStreamed = true
|
||||
|
||||
if inText {
|
||||
textAccum.WriteString(evt.Delta.Text)
|
||||
}
|
||||
case "input_json_delta":
|
||||
activeTool.json.WriteString(evt.Delta.PartialJSON)
|
||||
}
|
||||
|
||||
case "content_block_stop":
|
||||
if inText {
|
||||
assistantBlocks = append(assistantBlocks,
|
||||
anthropic.NewTextBlock(textAccum.String()),
|
||||
)
|
||||
|
||||
inText = false
|
||||
}
|
||||
|
||||
if activeTool.id != "" {
|
||||
inputJSON := json.RawMessage(activeTool.json.String())
|
||||
if len(inputJSON) == 0 {
|
||||
inputJSON = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
assistantBlocks = append(assistantBlocks, anthropic.ContentBlockParamUnion{
|
||||
OfToolUse: &anthropic.ToolUseBlockParam{
|
||||
ID: activeTool.id,
|
||||
Name: activeTool.name,
|
||||
Input: inputJSON,
|
||||
},
|
||||
})
|
||||
|
||||
activeTool.id = ""
|
||||
activeTool.name = ""
|
||||
activeTool.json.Reset()
|
||||
}
|
||||
|
||||
case "message_delta":
|
||||
stopReason = evt.Delta.StopReason
|
||||
s.man.metrics.recordTokenUsage(
|
||||
evt.Usage.InputTokens,
|
||||
evt.Usage.OutputTokens,
|
||||
evt.Usage.CacheCreationInputTokens,
|
||||
evt.Usage.CacheReadInputTokens,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := stream.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Signal end of this call's text run so the client can move
|
||||
// intermediate narration into a transient status pill and only
|
||||
// commit final answers into the persistent chat.
|
||||
if textStreamed {
|
||||
kind := protocol.TextEndKindMessage
|
||||
if stopReason == anthropic.StopReasonToolUse {
|
||||
kind = protocol.TextEndKindStatus
|
||||
}
|
||||
s.writer.WriteJSON(ctx, protocol.NewTextEndMessage(kind))
|
||||
}
|
||||
|
||||
if stopReason != anthropic.StopReasonToolUse {
|
||||
s.messages = append(s.messages, anthropic.MessageParam{
|
||||
Role: anthropic.MessageParamRoleAssistant,
|
||||
Content: assistantBlocks,
|
||||
})
|
||||
|
||||
return stopReason, nil
|
||||
}
|
||||
|
||||
// Persist the assistant turn (text + tool_use blocks) before
|
||||
// running the tools so a tool failure doesn't lose what the
|
||||
// model said.
|
||||
s.messages = append(s.messages, anthropic.MessageParam{
|
||||
Role: anthropic.MessageParamRoleAssistant,
|
||||
Content: assistantBlocks,
|
||||
})
|
||||
|
||||
toolResults := s.dispatchTools(ctx, assistantBlocks)
|
||||
|
||||
s.messages = append(s.messages, anthropic.MessageParam{
|
||||
Role: anthropic.MessageParamRoleUser,
|
||||
Content: toolResults,
|
||||
})
|
||||
|
||||
// Prune older read_block / read_document_summary results from
|
||||
// history when a newer call for the same (tool, doc, block)
|
||||
// supersedes them — keeps the conversation cheap.
|
||||
s.pruneStaleReads()
|
||||
|
||||
return stopReason, nil
|
||||
}
|
||||
|
||||
// dispatchTools splits the streamed tool_use blocks into reads and
|
||||
// writes, runs reads concurrently, gates writes behind a confirm
|
||||
// prompt, and returns the tool_result blocks in input order so the
|
||||
// next Anthropic call sees a consistent dialogue.
|
||||
func (s *Session) dispatchTools(
|
||||
ctx context.Context,
|
||||
assistantBlocks []anthropic.ContentBlockParamUnion,
|
||||
) []anthropic.ContentBlockParamUnion {
|
||||
type call struct {
|
||||
// idx is the position of the result block in the returned
|
||||
// slice; preserved so the order matches input.
|
||||
idx int
|
||||
id string
|
||||
name string
|
||||
args json.RawMessage
|
||||
write bool
|
||||
}
|
||||
|
||||
var (
|
||||
calls []call
|
||||
writeIndexes []int
|
||||
)
|
||||
|
||||
for _, block := range assistantBlocks {
|
||||
if block.OfToolUse == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
args, _ := block.OfToolUse.Input.(json.RawMessage)
|
||||
if args == nil {
|
||||
args = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
c := call{
|
||||
idx: len(calls),
|
||||
id: block.OfToolUse.ID,
|
||||
name: block.OfToolUse.Name,
|
||||
args: args,
|
||||
write: tools.IsWrite(tools.Name(block.OfToolUse.Name)),
|
||||
}
|
||||
|
||||
if c.write {
|
||||
writeIndexes = append(writeIndexes, c.idx)
|
||||
}
|
||||
|
||||
calls = append(calls, c)
|
||||
}
|
||||
|
||||
results := make([]anthropic.ContentBlockParamUnion, len(calls))
|
||||
|
||||
approved := true
|
||||
|
||||
if len(writeIndexes) > 0 {
|
||||
s.mu.Lock()
|
||||
autoApprove := s.autoApproveTurn
|
||||
s.mu.Unlock()
|
||||
|
||||
if !autoApprove {
|
||||
actions := make([]protocol.ConfirmAction, 0, len(writeIndexes))
|
||||
for _, i := range writeIndexes {
|
||||
summary := s.tools.Summarize(ctx, tools.Name(calls[i].name), calls[i].args)
|
||||
actions = append(actions, protocol.ConfirmAction{
|
||||
Tool: calls[i].name,
|
||||
DocumentID: summary.DocumentID,
|
||||
DocumentName: summary.DocumentName,
|
||||
Summary: summary.Summary,
|
||||
})
|
||||
}
|
||||
|
||||
approved = s.requestConfirmation(ctx, actions)
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := range calls {
|
||||
i := i
|
||||
c := calls[i]
|
||||
|
||||
if c.write && !approved {
|
||||
results[c.idx] = makeToolResult(c.id, mustJSON(map[string]any{
|
||||
"approved": false,
|
||||
"reason": "user declined",
|
||||
}))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
results[c.idx] = s.runTool(ctx, c.id, tools.Name(c.name), c.args)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// runTool executes one tool and packages its outcome as a
|
||||
// tool_result block. The result envelope is JSON: the success path
|
||||
// is the tool's own JSON output; the failure path is
|
||||
// {"error": "..."}.
|
||||
func (s *Session) runTool(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
name tools.Name,
|
||||
args json.RawMessage,
|
||||
) anthropic.ContentBlockParamUnion {
|
||||
if !tools.IsValid(name) {
|
||||
s.man.log.Warn("assistant called unknown tool",
|
||||
slog.String("tool", string(name)),
|
||||
)
|
||||
|
||||
return makeToolResult(id, mustJSON(map[string]any{"error": "unknown tool"}))
|
||||
}
|
||||
|
||||
// Surface a short pill to the client describing what we're
|
||||
// about to do. Skipped for tools without a friendly label
|
||||
// (list_documents, get_document, set_document_icon).
|
||||
if label := s.tools.ToolStatusLabel(ctx, name, args); label != "" {
|
||||
s.writer.WriteJSON(ctx, protocol.NewToolStatusMessage(string(name), label))
|
||||
}
|
||||
|
||||
start := timeutil.Now()
|
||||
result, err := s.tools.Execute(ctx, name, args)
|
||||
durationMs := time.Since(start).Milliseconds()
|
||||
|
||||
status := "success"
|
||||
|
||||
if err != nil {
|
||||
status = "error"
|
||||
result = mustJSON(map[string]any{"error": err.Error()})
|
||||
|
||||
s.man.log.Warn("assistant tool error",
|
||||
slog.String("tool", string(name)),
|
||||
slog.String("error", err.Error()),
|
||||
slog.String("args", truncateForLog(args, 512)),
|
||||
slog.Int64("duration_ms", durationMs),
|
||||
)
|
||||
}
|
||||
|
||||
if len(result) > _maxToolResultBytes {
|
||||
result = mustJSON(map[string]any{
|
||||
"error": "result too large; use a more specific query to narrow it down",
|
||||
})
|
||||
|
||||
status = "error"
|
||||
|
||||
s.man.log.Warn("assistant tool result truncated (too large)",
|
||||
slog.String("tool", string(name)),
|
||||
slog.Int("size_bytes", len(result)),
|
||||
)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
s.man.log.Debug("assistant tool ok",
|
||||
slog.String("tool", string(name)),
|
||||
slog.String("args", truncateForLog(args, 256)),
|
||||
slog.Int64("duration_ms", durationMs),
|
||||
)
|
||||
}
|
||||
|
||||
s.man.metrics.recordToolCall(name, status)
|
||||
s.man.metrics.recordToolDuration(name, time.Since(start).Seconds())
|
||||
|
||||
return makeToolResult(id, result)
|
||||
}
|
||||
|
||||
// truncateForLog returns a short, single-line preview of raw for
|
||||
// inclusion in log fields. Strings longer than max are tail-elided
|
||||
// with an ellipsis so a long tool argument doesn't fill the log.
|
||||
func truncateForLog(raw json.RawMessage, max int) string {
|
||||
if len(raw) <= max {
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
return string(raw[:max]) + "…"
|
||||
}
|
||||
|
||||
// requestConfirmation sends a confirm_request and blocks until the
|
||||
// client returns a confirm_response (or the timeout fires). Returns
|
||||
// true when the user approved the batch, false otherwise.
|
||||
func (s *Session) requestConfirmation(ctx context.Context, actions []protocol.ConfirmAction) bool {
|
||||
turnID := xid.New().String()
|
||||
ch := make(chan bool, 1)
|
||||
|
||||
s.confirmMu.Lock()
|
||||
s.confirmCh = ch
|
||||
s.confirmID = turnID
|
||||
s.confirmMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.confirmMu.Lock()
|
||||
s.confirmCh = nil
|
||||
s.confirmID = ""
|
||||
s.confirmMu.Unlock()
|
||||
}()
|
||||
|
||||
tools := make([]string, 0, len(actions))
|
||||
for _, a := range actions {
|
||||
tools = append(tools, a.Tool)
|
||||
}
|
||||
|
||||
s.man.log.Info("assistant confirm requested",
|
||||
slog.String("turn_id", turnID),
|
||||
slog.Int("action_count", len(actions)),
|
||||
slog.Any("tools", tools),
|
||||
)
|
||||
|
||||
s.writer.WriteJSON(ctx, protocol.NewConfirmRequest(turnID, actions))
|
||||
|
||||
select {
|
||||
case approved := <-ch:
|
||||
s.man.log.Info("assistant confirm answered",
|
||||
slog.String("turn_id", turnID),
|
||||
slog.Bool("approved", approved),
|
||||
)
|
||||
|
||||
return approved
|
||||
case <-time.After(_confirmTimeout):
|
||||
s.man.log.Warn("assistant confirm timeout",
|
||||
slog.String("turn_id", turnID),
|
||||
slog.Duration("timeout", _confirmTimeout),
|
||||
)
|
||||
|
||||
return false
|
||||
case <-ctx.Done():
|
||||
s.man.log.Info("assistant confirm cancelled by context",
|
||||
slog.String("turn_id", turnID),
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// deliverConfirmResponse routes a client confirm_response into the
|
||||
// pending confirmation channel. A mismatched turn id is dropped
|
||||
// silently — only the in-flight confirm reads the channel, and
|
||||
// stale answers from prior turns shouldn't fire. When all is set
|
||||
// alongside approved, the rest of the current turn skips the
|
||||
// prompt entirely.
|
||||
func (s *Session) deliverConfirmResponse(turnID string, approved, all bool) {
|
||||
s.confirmMu.Lock()
|
||||
defer s.confirmMu.Unlock()
|
||||
|
||||
if s.confirmCh == nil || s.confirmID != turnID {
|
||||
return
|
||||
}
|
||||
|
||||
if approved && all {
|
||||
s.mu.Lock()
|
||||
s.autoApproveTurn = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Non-blocking send: the channel is buffered (cap 1); a second
|
||||
// answer for the same turn is dropped.
|
||||
select {
|
||||
case s.confirmCh <- approved:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// pruneStaleReads collapses older read tool results when a newer
|
||||
// call for the same (tool, document, block) supersedes them. This
|
||||
// keeps the conversation history small even when the model
|
||||
// re-reads the same document across turns.
|
||||
func (s *Session) pruneStaleReads() {
|
||||
type readKey struct {
|
||||
tool, doc, block string
|
||||
}
|
||||
|
||||
// Walk backwards and record the latest tool_use for each
|
||||
// read-tool key. Earlier matches (smaller positions) are the
|
||||
// ones we'll prune.
|
||||
lastSeen := map[readKey]int{}
|
||||
|
||||
for i, msg := range s.messages {
|
||||
if msg.Role != anthropic.MessageParamRoleAssistant {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, block := range msg.Content {
|
||||
if block.OfToolUse == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
name := tools.Name(block.OfToolUse.Name)
|
||||
if name != tools.NameReadBlock &&
|
||||
name != tools.NameReadDocumentSummary {
|
||||
continue
|
||||
}
|
||||
|
||||
args, _ := block.OfToolUse.Input.(json.RawMessage)
|
||||
|
||||
var probe struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
BlockUID string `json:"block_uid"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &probe); err != nil {
|
||||
// Malformed args from a prior turn shouldn't block
|
||||
// pruning the rest of the history. Log and skip this
|
||||
// entry; probe stays at zero values, so its readKey
|
||||
// will just collide with any other malformed read.
|
||||
s.man.log.Warn("pruneStaleReads: probe unmarshal failed",
|
||||
slog.String("tool", string(name)),
|
||||
slog.String("tool_use_id", block.OfToolUse.ID),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
k := readKey{tool: string(name), doc: probe.DocumentID, block: probe.BlockUID}
|
||||
if last, ok := lastSeen[k]; ok && last != i {
|
||||
s.prunePriorReadResult(last, block.OfToolUse.ID)
|
||||
}
|
||||
|
||||
lastSeen[k] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// prunePriorReadResult replaces an older read result (the user
|
||||
// message following the assistant turn at assistantIdx) with a
|
||||
// compact placeholder, keeping the conversation honest about what
|
||||
// was once read without paying tokens to keep the full content.
|
||||
func (s *Session) prunePriorReadResult(assistantIdx int, toolUseID string) {
|
||||
resultIdx := assistantIdx + 1
|
||||
if resultIdx >= len(s.messages)-1 {
|
||||
return
|
||||
}
|
||||
|
||||
resultMsg := &s.messages[resultIdx]
|
||||
for j, rb := range resultMsg.Content {
|
||||
if rb.OfToolResult == nil || rb.OfToolResult.ToolUseID != toolUseID {
|
||||
continue
|
||||
}
|
||||
|
||||
resultMsg.Content[j].OfToolResult.Content = []anthropic.ToolResultBlockParamContentUnion{
|
||||
{
|
||||
OfText: &anthropic.TextBlockParam{
|
||||
Text: `{"pruned": "A newer read for the same target exists later in the conversation. Call the read tool again if you need current content."}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// trimMessages keeps the conversation history under _maxMessages.
|
||||
// We trim from the front in pairs to avoid stranding a user message
|
||||
// without its assistant follow-up (or vice versa).
|
||||
func (s *Session) trimMessages() {
|
||||
if len(s.messages) <= _maxMessages {
|
||||
return
|
||||
}
|
||||
|
||||
excess := len(s.messages) - _maxMessages
|
||||
if excess%2 != 0 {
|
||||
excess++
|
||||
}
|
||||
|
||||
s.messages = s.messages[excess:]
|
||||
}
|
||||
|
||||
// makeToolResult wraps a json payload as a ToolResultBlockParam.
|
||||
func makeToolResult(toolUseID string, result json.RawMessage) anthropic.ContentBlockParamUnion {
|
||||
return anthropic.ContentBlockParamUnion{
|
||||
OfToolResult: &anthropic.ToolResultBlockParam{
|
||||
ToolUseID: toolUseID,
|
||||
Content: []anthropic.ToolResultBlockParamContentUnion{
|
||||
{
|
||||
OfText: &anthropic.TextBlockParam{
|
||||
Text: string(result),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// mustJSON marshals v as JSON; on failure (which only happens for
|
||||
// malformed input) it returns a hand-rolled error envelope so the
|
||||
// session never panics.
|
||||
func mustJSON(v any) json.RawMessage {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return json.RawMessage(fmt.Sprintf(`{"error": %q}`, err.Error()))
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
267
server/core/internal/assistant/tools/docwalk.go
Normal file
267
server/core/internal/assistant/tools/docwalk.go
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"github.com/oxynote/heimdall/internal/document"
|
||||
)
|
||||
|
||||
// docSummaryEntry is one row in the assistant-facing document
|
||||
// summary. Every directly editable block surfaces with its uid so
|
||||
// the assistant can target edits or deletes precisely.
|
||||
type docSummaryEntry struct {
|
||||
// UID is the block's uid attribute.
|
||||
UID string `json:"uid"`
|
||||
|
||||
// Kind is the canonical block kind ("paragraph", "heading",
|
||||
// "callout", "bullet_list", "list_item", "split_doc", …). For
|
||||
// nested children we emit the inner kind (e.g. "list_item")
|
||||
// alongside ParentUID so the AI can reason about hierarchy.
|
||||
Kind string `json:"kind"`
|
||||
|
||||
// Text is the flattened text of the block's subtree, useful for
|
||||
// the AI to identify the block by its content.
|
||||
Text string `json:"text,omitempty"`
|
||||
|
||||
// Depth is the nesting level in this summary (0 = top-level
|
||||
// document children).
|
||||
Depth int `json:"depth"`
|
||||
|
||||
// ParentUID is the uid of the enclosing block when the entry
|
||||
// is nested. Empty at depth 0.
|
||||
ParentUID string `json:"parent_uid,omitempty"`
|
||||
|
||||
// Attrs surfaces the small per-kind attrs the AI might want
|
||||
// (heading.level, callout.icon, code.language, list task
|
||||
// item.checked). Opaque attrs (metric configs) are omitted.
|
||||
Attrs map[string]any `json:"attrs,omitempty"`
|
||||
}
|
||||
|
||||
// walkDocForAssistant builds the flat list of summary entries the
|
||||
// assistant uses. Each entry preserves document order; nested
|
||||
// blocks follow their parent with depth/parent_uid set so the AI
|
||||
// can reconstruct the hierarchy if needed.
|
||||
//
|
||||
// Macro internals (the LeftSide/RightSide wrappers of
|
||||
// splitDocumentation, the four-deep nesting inside a parameter
|
||||
// list) are not exposed: the AI sees the macro as a single entry
|
||||
// and drills into it via read_block when it needs the detail.
|
||||
func walkDocForAssistant(blocks []document.Block) []docSummaryEntry {
|
||||
w := &docWalker{}
|
||||
w.walkLevel(blocks, 0, "")
|
||||
|
||||
return w.out
|
||||
}
|
||||
|
||||
type docWalker struct {
|
||||
out []docSummaryEntry
|
||||
}
|
||||
|
||||
// walkLevel emits one entry per block at the current level, then
|
||||
// recurses into children for compound kinds whose children are
|
||||
// individually editable (lists, callout, blockquote, metric_grid).
|
||||
// Macro kinds (split_doc, param_list) emit a single entry without
|
||||
// descending — their internals are not editable as standalone
|
||||
// blocks.
|
||||
func (w *docWalker) walkLevel(blocks []document.Block, depth int, parentUID string) {
|
||||
for _, b := range blocks {
|
||||
uid, _ := b.UID()
|
||||
if uid == "" {
|
||||
// A block without a uid can't be referenced from any
|
||||
// edit tool, so it's not useful to surface.
|
||||
continue
|
||||
}
|
||||
|
||||
switch b.Type {
|
||||
case document.BlockNodeParagraph,
|
||||
document.BlockNodeHeading,
|
||||
document.BlockNodeBlockquote,
|
||||
document.BlockNodeCodeBlock,
|
||||
document.BlockNodeMermaidBlock,
|
||||
document.BlockNodeHorizontalRule,
|
||||
document.BlockNodeImageBlock,
|
||||
document.BlockNodeFigmaBlock:
|
||||
w.emit(b, uid, depth, parentUID)
|
||||
|
||||
case document.BlockNodeBulletList,
|
||||
document.BlockNodeOrderedList,
|
||||
document.BlockNodeTaskList:
|
||||
w.emit(b, uid, depth, parentUID)
|
||||
w.walkLevel(b.Content, depth+1, uid)
|
||||
|
||||
case document.BlockNodeListItem, document.BlockNodeTaskItem:
|
||||
w.emit(b, uid, depth, parentUID)
|
||||
// The visible content of a list item is its first
|
||||
// child paragraph; deeper recursion isn't useful.
|
||||
|
||||
case document.BlockNodeCalloutBlock:
|
||||
w.emit(b, uid, depth, parentUID)
|
||||
w.walkLevel(b.Content, depth+1, uid)
|
||||
|
||||
case document.BlockNodeMetricGrid:
|
||||
w.emit(b, uid, depth, parentUID)
|
||||
w.walkLevel(b.Content, depth+1, uid)
|
||||
|
||||
case document.BlockNodeMetricBlock:
|
||||
w.emit(b, uid, depth, parentUID)
|
||||
|
||||
case document.BlockNodeTitledCodeBlock:
|
||||
// Surface the titled code as a single entry. It only
|
||||
// exists inside split_doc.right and is editable as a
|
||||
// unit; the AI replaces it whole rather than poking at
|
||||
// its codeBlockTitle / codeBlock children.
|
||||
w.emit(b, uid, depth, parentUID)
|
||||
|
||||
case document.BlockNodeSplitDoc:
|
||||
w.emitSplitDoc(b, uid, depth, parentUID)
|
||||
|
||||
case document.BlockNodeParamList:
|
||||
w.emitParamList(b, uid, depth, parentUID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// emit creates the standard summary entry for a block and appends
|
||||
// it to the walker's output.
|
||||
func (w *docWalker) emit(b document.Block, uid string, depth int, parentUID string) {
|
||||
entry := docSummaryEntry{
|
||||
UID: uid,
|
||||
Kind: canonicalKindForPM(b.Type),
|
||||
Text: b.Flatten(),
|
||||
Depth: depth,
|
||||
ParentUID: parentUID,
|
||||
Attrs: summaryAttrs(b),
|
||||
}
|
||||
|
||||
w.out = append(w.out, entry)
|
||||
}
|
||||
|
||||
// emitSplitDoc emits one entry for a splitDocumentation node with
|
||||
// a text preview drawn from both sides. Children are not descended
|
||||
// — the AI uses read_block to see the macro's structure when it
|
||||
// wants to edit individual pieces.
|
||||
func (w *docWalker) emitSplitDoc(b document.Block, uid string, depth int, parentUID string) {
|
||||
w.out = append(w.out, docSummaryEntry{
|
||||
UID: uid,
|
||||
Kind: "split_doc",
|
||||
Text: b.Flatten(),
|
||||
Depth: depth,
|
||||
ParentUID: parentUID,
|
||||
Attrs: summaryAttrs(b),
|
||||
})
|
||||
}
|
||||
|
||||
// emitParamList emits one entry for a splitDocumentationParameterList
|
||||
// node with text drawn from its header and item names. Children are
|
||||
// not descended — see emitSplitDoc.
|
||||
func (w *docWalker) emitParamList(b document.Block, uid string, depth int, parentUID string) {
|
||||
w.out = append(w.out, docSummaryEntry{
|
||||
UID: uid,
|
||||
Kind: "split_doc_param_list",
|
||||
Text: b.Flatten(),
|
||||
Depth: depth,
|
||||
ParentUID: parentUID,
|
||||
})
|
||||
}
|
||||
|
||||
// canonicalKindForPM maps a ProseMirror node type to the canonical
|
||||
// kind name the assistant uses elsewhere. Keeping the AI on one
|
||||
// vocabulary (snake_case canonical names) avoids surprises when it
|
||||
// reads a summary, then writes via tools that use the canonical
|
||||
// names.
|
||||
func canonicalKindForPM(pm document.BlockNodeType) string {
|
||||
switch pm {
|
||||
case document.BlockNodeParagraph:
|
||||
return "paragraph"
|
||||
case document.BlockNodeHeading:
|
||||
return "heading"
|
||||
case document.BlockNodeBlockquote:
|
||||
return "blockquote"
|
||||
case document.BlockNodeBulletList:
|
||||
return "bullet_list"
|
||||
case document.BlockNodeOrderedList:
|
||||
return "ordered_list"
|
||||
case document.BlockNodeTaskList:
|
||||
return "task_list"
|
||||
case document.BlockNodeListItem:
|
||||
return "list_item"
|
||||
case document.BlockNodeTaskItem:
|
||||
return "task_item"
|
||||
case document.BlockNodeCalloutBlock:
|
||||
return "callout"
|
||||
case document.BlockNodeCodeBlock:
|
||||
return "code"
|
||||
case document.BlockNodeTitledCodeBlock:
|
||||
return "titled_code"
|
||||
case document.BlockNodeMermaidBlock:
|
||||
return "mermaid"
|
||||
case document.BlockNodeHorizontalRule:
|
||||
return "horizontal_rule"
|
||||
case document.BlockNodeImageBlock:
|
||||
return "image"
|
||||
case document.BlockNodeFigmaBlock:
|
||||
return "figma"
|
||||
case document.BlockNodeMetricBlock:
|
||||
return "metric"
|
||||
case document.BlockNodeMetricGrid:
|
||||
return "metric_grid"
|
||||
case document.BlockNodeSplitDoc:
|
||||
return "split_doc"
|
||||
case document.BlockNodeParamList:
|
||||
return "split_doc_param_list"
|
||||
}
|
||||
|
||||
return string(pm)
|
||||
}
|
||||
|
||||
// summaryAttrs filters the block's raw PM attrs down to the small
|
||||
// per-kind subset the AI cares about for reads. uid is dropped
|
||||
// (already on the entry); opaque metric configurations are dropped
|
||||
// because they would balloon the payload and the AI never edits
|
||||
// them by hand anyway.
|
||||
func summaryAttrs(b document.Block) map[string]any {
|
||||
switch b.Type {
|
||||
case document.BlockNodeHeading:
|
||||
if v, ok := b.Attrs["level"]; ok {
|
||||
return map[string]any{"level": v}
|
||||
}
|
||||
case document.BlockNodeCalloutBlock:
|
||||
if v, ok := b.Attrs["icon"]; ok {
|
||||
return map[string]any{"icon": v}
|
||||
}
|
||||
case document.BlockNodeCodeBlock:
|
||||
if v, ok := b.Attrs["language"]; ok && v != "" {
|
||||
return map[string]any{"language": v}
|
||||
}
|
||||
case document.BlockNodeTaskItem:
|
||||
if v, ok := b.Attrs["checked"]; ok {
|
||||
return map[string]any{"checked": v}
|
||||
}
|
||||
case document.BlockNodeImageBlock:
|
||||
out := map[string]any{}
|
||||
for _, k := range []string{"src", "alt", "title", "width"} {
|
||||
if v, ok := b.Attrs[k]; ok && v != nil && v != "" {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
case document.BlockNodeFigmaBlock:
|
||||
out := map[string]any{}
|
||||
for _, k := range []string{"src", "width", "height"} {
|
||||
if v, ok := b.Attrs[k]; ok && v != nil && v != "" {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
case document.BlockNodeSplitDoc:
|
||||
if v, ok := b.Attrs["inversed"]; ok {
|
||||
if vb, ok := v.(bool); ok && vb {
|
||||
return map[string]any{"inversed": true}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
320
server/core/internal/assistant/tools/reads.go
Normal file
320
server/core/internal/assistant/tools/reads.go
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/oxynote/heimdall/internal/document"
|
||||
"github.com/oxynote/heimdall/internal/document/aiblock"
|
||||
"github.com/oxynote/purse/util/errutil"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// _searchLimitDefault is the cap applied when search_documents is
|
||||
// called without a limit argument.
|
||||
const _searchLimitDefault = 20
|
||||
|
||||
// _searchLimitMax is the hard cap; larger requested values are
|
||||
// silently clipped so the AI can't accidentally page through
|
||||
// thousands of rows.
|
||||
const _searchLimitMax = 50
|
||||
|
||||
// docTreeNode is the shape returned by list_documents. It mirrors
|
||||
// document.Summary but uses snake_case keys so the AI consumes a
|
||||
// consistent vocabulary with the rest of the tool surface.
|
||||
type docTreeNode struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Children []docTreeNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
func (m *Manager) listDocuments(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
ParentID string `json:"parent_id"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("list_documents: invalid input: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
tree document.Summaries
|
||||
err error
|
||||
)
|
||||
|
||||
if in.ParentID == "" {
|
||||
tree, err = m.db.FetchDocumentTree(ctx, m.orgID)
|
||||
} else {
|
||||
parentID, perr := xid.FromString(in.ParentID)
|
||||
if perr != nil {
|
||||
return nil, fmt.Errorf("list_documents: parent_id is not a valid xid: %w", perr)
|
||||
}
|
||||
|
||||
tree, err = m.db.FetchDocumentTreeByDocumentParentID(ctx, null.ValueFrom(parentID), m.orgID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list_documents: fetch tree: %w", err)
|
||||
}
|
||||
|
||||
out := struct {
|
||||
Documents []docTreeNode `json:"documents"`
|
||||
}{
|
||||
Documents: summariesToTree(tree),
|
||||
}
|
||||
|
||||
return marshalResult(out)
|
||||
}
|
||||
|
||||
func (m *Manager) getDocument(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("get_document: invalid input: %w", err)
|
||||
}
|
||||
|
||||
docID, err := xid.FromString(in.DocumentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get_document: document_id is not a valid xid: %w", err)
|
||||
}
|
||||
|
||||
doc, err := m.db.FetchDocument(ctx, docID, m.orgID, document.DefaultBranch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get_document: fetch: %w", err)
|
||||
}
|
||||
|
||||
out := struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
BranchID string `json:"branch_id"`
|
||||
BranchName string `json:"branch_name"`
|
||||
Protected bool `json:"protected"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}{
|
||||
ID: doc.ID.String(),
|
||||
Name: doc.DocumentName,
|
||||
Icon: doc.Icon,
|
||||
BranchID: doc.BranchID.String(),
|
||||
BranchName: doc.BranchName,
|
||||
Protected: doc.Protected,
|
||||
UpdatedAt: doc.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
|
||||
if doc.ParentID.Valid {
|
||||
out.ParentID = doc.ParentID.V.String()
|
||||
}
|
||||
|
||||
return marshalResult(out)
|
||||
}
|
||||
|
||||
func (m *Manager) readDocumentSummary(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("read_document_summary: invalid input: %w", err)
|
||||
}
|
||||
|
||||
docID, err := xid.FromString(in.DocumentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read_document_summary: document_id is not a valid xid: %w", err)
|
||||
}
|
||||
|
||||
content, err := m.db.FetchMainBranchContent(ctx, docID, m.orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read_document_summary: fetch content: %w", err)
|
||||
}
|
||||
|
||||
entries := walkDocForAssistant(content.Content.Content)
|
||||
|
||||
out := struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
DocumentName string `json:"document_name"`
|
||||
Blocks []docSummaryEntry `json:"blocks"`
|
||||
}{
|
||||
DocumentID: docID.String(),
|
||||
DocumentName: content.DocumentName,
|
||||
Blocks: entries,
|
||||
}
|
||||
|
||||
return marshalResult(out)
|
||||
}
|
||||
|
||||
func (m *Manager) readBlock(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
BlockUID string `json:"block_uid"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("read_block: invalid input: %w", err)
|
||||
}
|
||||
|
||||
docID, err := xid.FromString(in.DocumentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read_block: document_id is not a valid xid: %w", err)
|
||||
}
|
||||
|
||||
if in.BlockUID == "" {
|
||||
return nil, errors.New("read_block: block_uid is required")
|
||||
}
|
||||
|
||||
content, err := m.db.FetchMainBranchContent(ctx, docID, m.orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read_block: fetch content: %w", err)
|
||||
}
|
||||
|
||||
block, ok := findBlockByUID(content.Content.Content, in.BlockUID)
|
||||
if !ok {
|
||||
return nil, errutil.ErrNotFound
|
||||
}
|
||||
|
||||
canon, err := aiblock.Compact(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read_block: compact: %w", err)
|
||||
}
|
||||
|
||||
return marshalResult(canon)
|
||||
}
|
||||
|
||||
func (m *Manager) searchDocuments(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("search_documents: invalid input: %w", err)
|
||||
}
|
||||
|
||||
if in.Query == "" {
|
||||
return nil, errors.New("search_documents: query is required")
|
||||
}
|
||||
|
||||
limit := in.Limit
|
||||
if limit <= 0 {
|
||||
limit = _searchLimitDefault
|
||||
}
|
||||
|
||||
if limit > _searchLimitMax {
|
||||
limit = _searchLimitMax
|
||||
}
|
||||
|
||||
blocks, err := m.search.SearchDocumentBlocks(ctx, m.orgID, in.Query, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search_documents: search: %w", err)
|
||||
}
|
||||
|
||||
// The index stores block text keyed by (document, block uid) but
|
||||
// not display names; join names in from the document tree so the
|
||||
// AI can talk about hits without a follow-up lookup per document.
|
||||
names := map[string]string{}
|
||||
|
||||
if tree, err := m.db.FetchDocumentTree(ctx, m.orgID); err == nil {
|
||||
collectDocumentNames(tree, names)
|
||||
}
|
||||
|
||||
hits := make([]searchHit, 0, len(blocks))
|
||||
|
||||
for _, b := range blocks {
|
||||
hits = append(hits, searchHit{
|
||||
DocumentID: b.DocumentID.String(),
|
||||
DocumentName: names[b.DocumentID.String()],
|
||||
BlockUID: b.ID,
|
||||
Text: b.Text,
|
||||
})
|
||||
}
|
||||
|
||||
out := struct {
|
||||
Hits []searchHit `json:"hits"`
|
||||
}{
|
||||
Hits: hits,
|
||||
}
|
||||
|
||||
return marshalResult(out)
|
||||
}
|
||||
|
||||
// searchHit is one search_documents result row.
|
||||
type searchHit struct {
|
||||
// DocumentID is the document containing the matching block.
|
||||
DocumentID string `json:"document_id"`
|
||||
|
||||
// DocumentName is the document's display name, when resolvable
|
||||
// from the tree. Empty if the document vanished between the
|
||||
// index update and the search.
|
||||
DocumentName string `json:"document_name,omitempty"`
|
||||
|
||||
// BlockUID is the matching block's uid attribute, usable with
|
||||
// read_block and the edit tools.
|
||||
BlockUID string `json:"block_uid"`
|
||||
|
||||
// Text is the block's indexed text.
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// collectDocumentNames flattens the summary tree into an id → name
|
||||
// map.
|
||||
func collectDocumentNames(ss document.Summaries, out map[string]string) {
|
||||
for _, s := range ss {
|
||||
out[s.ID.String()] = s.DocumentName
|
||||
|
||||
collectDocumentNames(s.Children, out)
|
||||
}
|
||||
}
|
||||
|
||||
// summariesToTree converts the document package's nested Summary
|
||||
// tree into the snake_case shape returned by list_documents.
|
||||
func summariesToTree(ss document.Summaries) []docTreeNode {
|
||||
if len(ss) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]docTreeNode, 0, len(ss))
|
||||
|
||||
for _, s := range ss {
|
||||
out = append(out, docTreeNode{
|
||||
ID: s.ID.String(),
|
||||
Name: s.DocumentName,
|
||||
Icon: s.Icon,
|
||||
Children: summariesToTree(s.Children),
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// findBlockByUID walks the document tree depth-first and returns
|
||||
// the block whose uid attribute equals target.
|
||||
func findBlockByUID(blocks []document.Block, target string) (document.Block, bool) {
|
||||
for _, b := range blocks {
|
||||
if uid, ok := b.UID(); ok && uid == target {
|
||||
return b, true
|
||||
}
|
||||
|
||||
if found, ok := findBlockByUID(b.Content, target); ok {
|
||||
return found, true
|
||||
}
|
||||
}
|
||||
|
||||
return document.Block{}, false
|
||||
}
|
||||
|
||||
// marshalResult is the single place tool result envelopes are
|
||||
// serialised; centralising it keeps the JSON shape consistent.
|
||||
func marshalResult(v any) (json.RawMessage, error) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal tool result: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
426
server/core/internal/assistant/tools/schema.go
Normal file
426
server/core/internal/assistant/tools/schema.go
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
anthropic "github.com/anthropics/anthropic-sdk-go"
|
||||
)
|
||||
|
||||
// AnthropicTools returns the tool definitions sent to Anthropic.
|
||||
// The schemas describe canonical-layer inputs: blocks use the
|
||||
// canonical (snake_case, macro) shape, never raw ProseMirror JSON.
|
||||
// The full schema documentation lives inline in the system prompt
|
||||
// so the model has the per-type rules without needing a separate
|
||||
// discovery tool.
|
||||
func AnthropicTools() []anthropic.ToolUnionParam {
|
||||
return []anthropic.ToolUnionParam{
|
||||
readListDocuments(),
|
||||
readGetDocument(),
|
||||
readDocumentSummaryTool(),
|
||||
readBlockTool(),
|
||||
searchDocumentsTool(),
|
||||
|
||||
writeCreateDocument(),
|
||||
writeDeleteDocument(),
|
||||
writeRenameDocument(),
|
||||
writeSetDocumentIcon(),
|
||||
writeMoveDocument(),
|
||||
writeInsertBlock(),
|
||||
writeAppendBlock(),
|
||||
writePrependBlock(),
|
||||
writeReplaceBlock(),
|
||||
writeUpdateBlockText(),
|
||||
writeUpdateBlockAttrs(),
|
||||
writeDeleteBlock(),
|
||||
}
|
||||
}
|
||||
|
||||
func readListDocuments() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NameListDocuments),
|
||||
Description: anthropic.String("List documents in the organisation. Returns the document tree (id, name, icon, children). Use parent_id to scope to one subtree; omit to list the whole org."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"parent_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional. Only return documents under this parent id. Omit for the full tree.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func readGetDocument() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NameGetDocument),
|
||||
Description: anthropic.String("Fetch metadata for one document: name, icon, parent_id, default branch id, protected flag, updated_at."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The document id.",
|
||||
},
|
||||
},
|
||||
Required: []string{"document_id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func readDocumentSummaryTool() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NameReadDocumentSummary),
|
||||
Description: anthropic.String("Return an ordered, compact summary of a document: per-block uid, kind, flattened source_text, surrounding_context (ancestor headings, list intro, doc title), and tags. Use this as your default way to read a document — it's ~5-10x cheaper than fetching full content."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The document id.",
|
||||
},
|
||||
},
|
||||
Required: []string{"document_id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func readBlockTool() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NameReadBlock),
|
||||
Description: anthropic.String("Return the full canonical content of one block by uid, including any nested children. Use this only when read_document_summary doesn't carry enough detail (e.g. you need the full structure of a split_doc or split_doc_param_list to edit it)."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The document id.",
|
||||
},
|
||||
"block_uid": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The block uid to fetch.",
|
||||
},
|
||||
},
|
||||
Required: []string{"document_id", "block_uid"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func searchDocumentsTool() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NameSearchDocuments),
|
||||
Description: anthropic.String("Full-text search across all documents in the organisation for blocks whose text matches the query. Returns hits with document_id, document_name, block_uid, text. Use this to find relevant context before editing or to surface candidate documents the user might be asking about."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"query": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The full-text search query (typo-tolerant, matches block text).",
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "Maximum number of hits to return. Defaults to 20; cap is 50.",
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeCreateDocument() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NameCreateDocument),
|
||||
Description: anthropic.String("Create a new document. Returns {document_id, branch_id}. The document starts with one empty paragraph; immediately follow up with append_block / insert_block calls to populate it."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Display name for the new document.",
|
||||
},
|
||||
"icon": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Lucide icon identifier (e.g. \"lucide:file-text\"). Defaults to \"lucide:file\" when empty.",
|
||||
},
|
||||
"parent_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional parent document id. Omit to create at the org root.",
|
||||
},
|
||||
},
|
||||
Required: []string{"name"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeDeleteDocument() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NameDeleteDocument),
|
||||
Description: anthropic.String("Delete a document. This is destructive — the user is always asked to confirm and there is no auto-approve."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The document id to delete.",
|
||||
},
|
||||
},
|
||||
Required: []string{"document_id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeRenameDocument() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NameRenameDocument),
|
||||
Description: anthropic.String("Change a document's display name. The change is applied live via hocuspocus."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The document id.",
|
||||
},
|
||||
"name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The new display name.",
|
||||
},
|
||||
},
|
||||
Required: []string{"document_id", "name"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeSetDocumentIcon() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NameSetDocumentIcon),
|
||||
Description: anthropic.String("Change a document's icon identifier (Lucide-style, e.g. \"lucide:rocket\")."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The document id.",
|
||||
},
|
||||
"icon": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The new icon identifier.",
|
||||
},
|
||||
},
|
||||
Required: []string{"document_id", "icon"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeMoveDocument() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NameMoveDocument),
|
||||
Description: anthropic.String("Re-parent a document. Omit new_parent_id to move the document to the org root."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The document to move.",
|
||||
},
|
||||
"new_parent_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional new parent document id; omit to move to the root.",
|
||||
},
|
||||
},
|
||||
Required: []string{"document_id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// blockSchema is the JSON schema shared by every write tool that
|
||||
// takes a canonical Block argument. The full shape is documented in
|
||||
// the system prompt; this object only enumerates the top-level
|
||||
// fields so Anthropic surfaces the correct argument names.
|
||||
var blockSchema = map[string]any{
|
||||
"type": "object",
|
||||
"description": "A canonical block. See the system prompt for the per-type schema.",
|
||||
"properties": map[string]any{
|
||||
"type": map[string]any{"type": "string"},
|
||||
"uid": map[string]any{"type": "string"},
|
||||
"text": map[string]any{"type": "string"},
|
||||
"attrs": map[string]any{"type": "object"},
|
||||
"items": map[string]any{"type": "array"},
|
||||
"task_items": map[string]any{"type": "array"},
|
||||
"left": map[string]any{"type": "array"},
|
||||
"right": map[string]any{"type": "array"},
|
||||
"header": map[string]any{"type": "string"},
|
||||
"params": map[string]any{"type": "array"},
|
||||
},
|
||||
"required": []string{"type"},
|
||||
}
|
||||
|
||||
func writeInsertBlock() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NameInsertBlock),
|
||||
Description: anthropic.String("Insert a single canonical block before or after a referenced block in the document. The reference block stays in place. Block uid is generated server-side if omitted."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The target document id.",
|
||||
},
|
||||
"reference_block_uid": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The uid of the block to insert relative to.",
|
||||
},
|
||||
"position": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"before", "after"},
|
||||
"description": "Insert side relative to the reference block.",
|
||||
},
|
||||
"block": blockSchema,
|
||||
},
|
||||
Required: []string{"document_id", "reference_block_uid", "position", "block"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeAppendBlock() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NameAppendBlock),
|
||||
Description: anthropic.String("Append a single canonical block at the end of a document."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The target document id.",
|
||||
},
|
||||
"block": blockSchema,
|
||||
},
|
||||
Required: []string{"document_id", "block"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writePrependBlock() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NamePrependBlock),
|
||||
Description: anthropic.String("Prepend a single canonical block at the start of a document."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The target document id.",
|
||||
},
|
||||
"block": blockSchema,
|
||||
},
|
||||
Required: []string{"document_id", "block"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeReplaceBlock() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NameReplaceBlock),
|
||||
Description: anthropic.String("Replace an existing block by uid with a new block. The replacement keeps the same position in its parent. Useful for changing a block's type or its full structure."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The target document id.",
|
||||
},
|
||||
"block_uid": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The uid of the block being replaced.",
|
||||
},
|
||||
"block": blockSchema,
|
||||
},
|
||||
Required: []string{"document_id", "block_uid", "block"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeUpdateBlockText() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NameUpdateBlockText),
|
||||
Description: anthropic.String("Replace the inline text of a text-bearing block (paragraph, heading, blockquote, code, mermaid, callout-shorthand). The block's type and attrs are preserved. text uses the canonical minimal-markdown subset (**bold**, *italic*, _underline_, ~~strike~~, `code`, [label](url)). For code/mermaid blocks, text is raw and markdown is not parsed."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The target document id.",
|
||||
},
|
||||
"block_uid": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The uid of the block whose text should be replaced.",
|
||||
},
|
||||
"text": map[string]any{
|
||||
"type": "string",
|
||||
"description": "New inline text in canonical markdown.",
|
||||
},
|
||||
},
|
||||
Required: []string{"document_id", "block_uid", "text"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeUpdateBlockAttrs() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NameUpdateBlockAttrs),
|
||||
Description: anthropic.String("Set or override the named attributes on an existing block. Unmentioned attributes are preserved; the uid attribute cannot be changed."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The target document id.",
|
||||
},
|
||||
"block_uid": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The uid of the block whose attrs should be updated.",
|
||||
},
|
||||
"attrs": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Attribute keys and values to set (e.g. {\"level\": 2}, {\"icon\": \"lucide:warning\"}).",
|
||||
},
|
||||
},
|
||||
Required: []string{"document_id", "block_uid", "attrs"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeDeleteBlock() anthropic.ToolUnionParam {
|
||||
return anthropic.ToolUnionParam{
|
||||
OfTool: &anthropic.ToolParam{
|
||||
Name: string(NameDeleteBlock),
|
||||
Description: anthropic.String("Delete a block from the document by uid. Destructive — the user is always asked to confirm."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The target document id.",
|
||||
},
|
||||
"block_uid": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The uid of the block to delete.",
|
||||
},
|
||||
},
|
||||
Required: []string{"document_id", "block_uid"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
384
server/core/internal/assistant/tools/summarize.go
Normal file
384
server/core/internal/assistant/tools/summarize.go
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/oxynote/heimdall/internal/document"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// ConfirmActionSummary is the human-readable description of a
|
||||
// pending write op surfaced to the confirm UI. Built from the same
|
||||
// JSON args the tool was about to receive: Summarize never executes
|
||||
// the operation.
|
||||
type ConfirmActionSummary struct {
|
||||
// Tool is the name of the tool being proposed.
|
||||
Tool string
|
||||
|
||||
// DocumentID is the document the op targets, when known.
|
||||
DocumentID string
|
||||
|
||||
// DocumentName is the document's display name, when the
|
||||
// summary helper could look it up.
|
||||
DocumentName string
|
||||
|
||||
// Summary is a single-line description (e.g. "Insert callout
|
||||
// after 'Rate limit' section").
|
||||
Summary string
|
||||
}
|
||||
|
||||
// parseToolArgs unmarshals the tool's JSON args into dst. The label
|
||||
// helpers in this file are best-effort: a malformed args payload
|
||||
// should degrade the label, not abort the surrounding flow. So we
|
||||
// log a warning and let dst keep its zero value rather than
|
||||
// propagating the error. Anthropic enforces the input schema at the
|
||||
// tool-call boundary, so a failure here is unusual and worth knowing
|
||||
// about.
|
||||
func (m *Manager) parseToolArgs(name Name, args json.RawMessage, dst any) {
|
||||
if err := json.Unmarshal(args, dst); err != nil {
|
||||
m.log.Warn("summarize: tool args unmarshal failed",
|
||||
slog.String("tool", string(name)),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ToolStatusLabel builds a one-line pill string describing what
|
||||
// the assistant is about to do (e.g. "Reading 'Cat Facts'"). Returns
|
||||
// an empty string for tools that are too noisy or too generic to
|
||||
// surface; those just run silently. The verb word at the start
|
||||
// matches the action verb the client uses to color the pill.
|
||||
func (m *Manager) ToolStatusLabel(ctx context.Context, name Name, args json.RawMessage) string {
|
||||
docName := ""
|
||||
if docID := m.pluckDocumentID(name, args); docID != "" {
|
||||
docName = m.lookupDocumentName(ctx, docID)
|
||||
}
|
||||
|
||||
subject := docName
|
||||
if subject == "" {
|
||||
subject = "document"
|
||||
}
|
||||
|
||||
switch name {
|
||||
case NameReadDocumentSummary:
|
||||
return fmt.Sprintf("Reading %s", subject)
|
||||
|
||||
case NameReadBlock:
|
||||
return fmt.Sprintf("Reading a block in %s", subject)
|
||||
|
||||
case NameSearchDocuments:
|
||||
var in struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
m.parseToolArgs(name, args, &in)
|
||||
if in.Query == "" {
|
||||
return "Searching documents"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Searching for %q", in.Query)
|
||||
|
||||
case NameCreateDocument:
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
m.parseToolArgs(name, args, &in)
|
||||
if in.Name == "" {
|
||||
return "Creating a document"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Creating %q", in.Name)
|
||||
|
||||
case NameDeleteDocument:
|
||||
return fmt.Sprintf("Deleting %s", subject)
|
||||
|
||||
case NameRenameDocument:
|
||||
return fmt.Sprintf("Renaming %s", subject)
|
||||
|
||||
case NameMoveDocument:
|
||||
return fmt.Sprintf("Moving %s", subject)
|
||||
|
||||
case NameInsertBlock,
|
||||
NameAppendBlock,
|
||||
NamePrependBlock,
|
||||
NameReplaceBlock,
|
||||
NameUpdateBlockText,
|
||||
NameUpdateBlockAttrs,
|
||||
NameDeleteBlock:
|
||||
return fmt.Sprintf("Updating %s", subject)
|
||||
}
|
||||
|
||||
// list_documents, get_document, set_document_icon: too noisy or
|
||||
// too generic; let them run without a pill.
|
||||
return ""
|
||||
}
|
||||
|
||||
// Summarize builds a ConfirmActionSummary from a tool name and its
|
||||
// raw JSON args. The function performs at most one DB read (to
|
||||
// resolve a document name when the tool carries a document_id); on
|
||||
// failure it falls back to a less specific summary rather than
|
||||
// surfacing an error.
|
||||
func (m *Manager) Summarize(ctx context.Context, name Name, args json.RawMessage) ConfirmActionSummary {
|
||||
out := ConfirmActionSummary{Tool: string(name)}
|
||||
|
||||
docID := m.pluckDocumentID(name, args)
|
||||
if docID != "" {
|
||||
out.DocumentID = docID
|
||||
out.DocumentName = m.lookupDocumentName(ctx, docID)
|
||||
}
|
||||
|
||||
out.Summary = m.describe(name, args, out.DocumentName)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// pluckDocumentID peeks at the JSON args for a document_id (or
|
||||
// new_parent_id for moves), returning the empty string when the
|
||||
// op doesn't reference an existing document (create_document) or
|
||||
// the JSON is malformed (in which case parseToolArgs has already
|
||||
// logged).
|
||||
func (m *Manager) pluckDocumentID(name Name, args json.RawMessage) string {
|
||||
if name == NameCreateDocument {
|
||||
return ""
|
||||
}
|
||||
|
||||
var probe struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
}
|
||||
|
||||
m.parseToolArgs(name, args, &probe)
|
||||
|
||||
return probe.DocumentID
|
||||
}
|
||||
|
||||
// lookupDocumentName fetches the document's display name. Failures
|
||||
// (bad id, not found, transient) return an empty string so the
|
||||
// confirm UI gracefully falls back to the id alone.
|
||||
func (m *Manager) lookupDocumentName(ctx context.Context, documentID string) string {
|
||||
id, err := xid.FromString(documentID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
doc, err := m.db.FetchDocument(ctx, id, m.orgID, document.DefaultBranch)
|
||||
if err != nil || doc == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return doc.DocumentName
|
||||
}
|
||||
|
||||
// describe builds the one-sentence Summary string for the confirm
|
||||
// UI. The text never quotes a uid (uids are internal identifiers);
|
||||
// instead it leans on document name plus a short description of
|
||||
// the change.
|
||||
func (m *Manager) describe(name Name, args json.RawMessage, docName string) string {
|
||||
subject := docName
|
||||
if subject == "" {
|
||||
subject = "document"
|
||||
}
|
||||
|
||||
switch name {
|
||||
case NameCreateDocument:
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
m.parseToolArgs(name, args, &in)
|
||||
if in.Name == "" {
|
||||
return "Create a new document"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Create document %q", in.Name)
|
||||
|
||||
case NameDeleteDocument:
|
||||
return fmt.Sprintf("Delete %s", subject)
|
||||
|
||||
case NameRenameDocument:
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
m.parseToolArgs(name, args, &in)
|
||||
if in.Name == "" {
|
||||
return fmt.Sprintf("Rename %s", subject)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Rename %s to %q", subject, in.Name)
|
||||
|
||||
case NameSetDocumentIcon:
|
||||
var in struct {
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
m.parseToolArgs(name, args, &in)
|
||||
|
||||
return fmt.Sprintf("Change icon of %s to %s", subject, in.Icon)
|
||||
|
||||
case NameMoveDocument:
|
||||
var in struct {
|
||||
NewParentID string `json:"new_parent_id"`
|
||||
}
|
||||
|
||||
m.parseToolArgs(name, args, &in)
|
||||
if in.NewParentID == "" {
|
||||
return fmt.Sprintf("Move %s to the org root", subject)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Move %s under another document", subject)
|
||||
|
||||
case NameInsertBlock:
|
||||
var in struct {
|
||||
Position string `json:"position"`
|
||||
Block struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"block"`
|
||||
}
|
||||
|
||||
m.parseToolArgs(name, args, &in)
|
||||
|
||||
return fmt.Sprintf("Insert %s %s a block in %s",
|
||||
blockKindLabel(in.Block.Type), in.Position, subject)
|
||||
|
||||
case NameAppendBlock:
|
||||
var in struct {
|
||||
Block struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"block"`
|
||||
}
|
||||
|
||||
m.parseToolArgs(name, args, &in)
|
||||
|
||||
return fmt.Sprintf("Append %s to %s", blockKindLabel(in.Block.Type), subject)
|
||||
|
||||
case NamePrependBlock:
|
||||
var in struct {
|
||||
Block struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"block"`
|
||||
}
|
||||
|
||||
m.parseToolArgs(name, args, &in)
|
||||
|
||||
return fmt.Sprintf("Prepend %s to %s", blockKindLabel(in.Block.Type), subject)
|
||||
|
||||
case NameReplaceBlock:
|
||||
var in struct {
|
||||
Block struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"block"`
|
||||
}
|
||||
|
||||
m.parseToolArgs(name, args, &in)
|
||||
|
||||
return fmt.Sprintf("Replace a block in %s with %s",
|
||||
subject, blockKindLabel(in.Block.Type))
|
||||
|
||||
case NameUpdateBlockText:
|
||||
var in struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
m.parseToolArgs(name, args, &in)
|
||||
preview := preview(in.Text, 60)
|
||||
if preview == "" {
|
||||
return fmt.Sprintf("Update text of a block in %s", subject)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Update a block in %s: %q", subject, preview)
|
||||
|
||||
case NameUpdateBlockAttrs:
|
||||
var in struct {
|
||||
Attrs map[string]any `json:"attrs"`
|
||||
}
|
||||
|
||||
m.parseToolArgs(name, args, &in)
|
||||
|
||||
keys := make([]string, 0, len(in.Attrs))
|
||||
for k := range in.Attrs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
return fmt.Sprintf("Update block attributes in %s", subject)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Update block %s in %s", strings.Join(keys, ", "), subject)
|
||||
|
||||
case NameDeleteBlock:
|
||||
return fmt.Sprintf("Delete a block in %s", subject)
|
||||
}
|
||||
|
||||
return string(name)
|
||||
}
|
||||
|
||||
// blockKindLabel turns a canonical type string into a friendlier
|
||||
// label for the confirm UI. Unknown types fall through verbatim.
|
||||
func blockKindLabel(kind string) string {
|
||||
switch kind {
|
||||
case "paragraph":
|
||||
return "a paragraph"
|
||||
case "heading":
|
||||
return "a heading"
|
||||
case "blockquote":
|
||||
return "a blockquote"
|
||||
case "bullet_list":
|
||||
return "a bullet list"
|
||||
case "ordered_list":
|
||||
return "an ordered list"
|
||||
case "task_list":
|
||||
return "a task list"
|
||||
case "callout":
|
||||
return "a callout"
|
||||
case "code":
|
||||
return "a code block"
|
||||
case "titled_code":
|
||||
return "a titled code block"
|
||||
case "mermaid":
|
||||
return "a mermaid diagram"
|
||||
case "horizontal_rule":
|
||||
return "a divider"
|
||||
case "image":
|
||||
return "an image"
|
||||
case "figma":
|
||||
return "a figma embed"
|
||||
case "metric":
|
||||
return "a metric"
|
||||
case "metric_grid":
|
||||
return "a metric grid"
|
||||
case "split_doc":
|
||||
return "a split documentation block"
|
||||
case "split_doc_param_list":
|
||||
return "a parameter list"
|
||||
}
|
||||
|
||||
if kind == "" {
|
||||
return "a block"
|
||||
}
|
||||
|
||||
return "a " + kind + " block"
|
||||
}
|
||||
|
||||
// preview returns at most max runes of s, collapsed to a single
|
||||
// line. Used for surfacing short snippets of an upcoming text edit
|
||||
// in the confirm UI.
|
||||
func preview(s string, max int) string {
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
if max <= 0 {
|
||||
return s
|
||||
}
|
||||
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
|
||||
return string(runes[:max]) + "…"
|
||||
}
|
||||
269
server/core/internal/assistant/tools/tools.go
Normal file
269
server/core/internal/assistant/tools/tools.go
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
// Package tools owns the AI assistant's tool surface: the read and
|
||||
// write tools the model can call, the per-tool input schemas
|
||||
// exposed to Anthropic, and the dispatcher that turns a tool_use
|
||||
// block into a tool_result. All tools execute server-side — there
|
||||
// is no client-side forwarding.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/oxynote/heimdall/internal/document"
|
||||
"github.com/oxynote/heimdall/internal/document/liveedit"
|
||||
"github.com/oxynote/heimdall/internal/document/searchgw"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// Name is the canonical identifier of one tool the assistant
|
||||
// exposes to Anthropic. Names use snake_case; they double as the
|
||||
// Anthropic tool_use.name and the protocol confirm-action's "tool"
|
||||
// field.
|
||||
type Name string
|
||||
|
||||
// Read tools — never confirmed. The assistant runs these
|
||||
// immediately on every relevant turn.
|
||||
const (
|
||||
NameListDocuments Name = "list_documents"
|
||||
NameGetDocument Name = "get_document"
|
||||
NameReadDocumentSummary Name = "read_document_summary"
|
||||
NameReadBlock Name = "read_block"
|
||||
NameSearchDocuments Name = "search_documents"
|
||||
)
|
||||
|
||||
// Write tools — always confirmed. Deletes are the most
|
||||
// destructive but the same confirm gate covers all of them.
|
||||
const (
|
||||
NameCreateDocument Name = "create_document"
|
||||
NameDeleteDocument Name = "delete_document"
|
||||
NameRenameDocument Name = "rename_document"
|
||||
NameSetDocumentIcon Name = "set_document_icon"
|
||||
NameMoveDocument Name = "move_document"
|
||||
NameInsertBlock Name = "insert_block"
|
||||
NameAppendBlock Name = "append_block"
|
||||
NamePrependBlock Name = "prepend_block"
|
||||
NameReplaceBlock Name = "replace_block"
|
||||
NameUpdateBlockText Name = "update_block_text"
|
||||
NameUpdateBlockAttrs Name = "update_block_attrs"
|
||||
NameDeleteBlock Name = "delete_block"
|
||||
)
|
||||
|
||||
// IsWrite reports whether the named tool requires user confirmation
|
||||
// before execution. Unknown names default to false (treated as a
|
||||
// read) so a typo in a tool_use name never silently bypasses the
|
||||
// confirm gate.
|
||||
func IsWrite(name Name) bool {
|
||||
switch name {
|
||||
case NameCreateDocument,
|
||||
NameDeleteDocument,
|
||||
NameRenameDocument,
|
||||
NameSetDocumentIcon,
|
||||
NameMoveDocument,
|
||||
NameInsertBlock,
|
||||
NameAppendBlock,
|
||||
NamePrependBlock,
|
||||
NameReplaceBlock,
|
||||
NameUpdateBlockText,
|
||||
NameUpdateBlockAttrs,
|
||||
NameDeleteBlock:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsValid reports whether name is one of the assistant's known
|
||||
// tools.
|
||||
func IsValid(name Name) bool {
|
||||
switch name {
|
||||
case NameListDocuments,
|
||||
NameGetDocument,
|
||||
NameReadDocumentSummary,
|
||||
NameReadBlock,
|
||||
NameSearchDocuments,
|
||||
NameCreateDocument,
|
||||
NameDeleteDocument,
|
||||
NameRenameDocument,
|
||||
NameSetDocumentIcon,
|
||||
NameMoveDocument,
|
||||
NameInsertBlock,
|
||||
NameAppendBlock,
|
||||
NamePrependBlock,
|
||||
NameReplaceBlock,
|
||||
NameUpdateBlockText,
|
||||
NameUpdateBlockAttrs,
|
||||
NameDeleteBlock:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// DB is the persistence surface the tools manager requires. The
|
||||
// db package's agent satisfies it.
|
||||
type DB interface {
|
||||
// FetchDocumentTree returns all documents for the org as a
|
||||
// nested summary tree (sort_index order). Used by
|
||||
// list_documents.
|
||||
FetchDocumentTree(ctx context.Context, organizationID string) (document.Summaries, error)
|
||||
|
||||
// FetchDocumentTreeByDocumentParentID returns the children of
|
||||
// parentID within the org (nil parentID = top-level). Used by
|
||||
// list_documents.
|
||||
FetchDocumentTreeByDocumentParentID(ctx context.Context, parentID null.Value[xid.ID], organizationID string) (document.Summaries, error)
|
||||
|
||||
// FetchDocument returns full document content for the named
|
||||
// branch. Used by get_document, read_document_summary,
|
||||
// read_block.
|
||||
FetchDocument(ctx context.Context, id xid.ID, organizationID string, branchName string) (*document.Document, error)
|
||||
|
||||
// FetchMainBranchContent returns the parsed main-branch
|
||||
// content of the document. Used when an op only needs the
|
||||
// content tree (no branch metadata).
|
||||
FetchMainBranchContent(ctx context.Context, docID xid.ID, organizationID string) (document.DocumentContent, error)
|
||||
|
||||
// InsertDocument creates a new document. Used by
|
||||
// create_document.
|
||||
InsertDocument(ctx context.Context, doc document.Document) error
|
||||
|
||||
// UpsertDocumentMaintainers adds the given user ids to a
|
||||
// document's maintainer set (idempotent via ON CONFLICT). Used
|
||||
// by create_document to make the requesting user a maintainer
|
||||
// of any document the assistant creates on their behalf.
|
||||
UpsertDocumentMaintainers(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error
|
||||
|
||||
// DeleteDocument removes a document. Used by delete_document.
|
||||
DeleteDocument(ctx context.Context, id xid.ID, organizationID string) error
|
||||
|
||||
// UpdateDocumentParentID re-parents a document. Used by
|
||||
// move_document.
|
||||
UpdateDocumentParentID(ctx context.Context, id xid.ID, parentID null.Value[xid.ID], organizationID string) error
|
||||
|
||||
// CheckDocumentExists reports whether the document exists in
|
||||
// the given org. Used by move_document to validate the new
|
||||
// parent before issuing UPDATE.
|
||||
CheckDocumentExists(ctx context.Context, id xid.ID, organizationID string) error
|
||||
}
|
||||
|
||||
// Searcher is the full-text search surface search_documents uses.
|
||||
// The document/searchgw Meilisearch client satisfies it.
|
||||
type Searcher interface {
|
||||
// SearchDocumentBlocks returns blocks whose text matches the
|
||||
// query, scoped to the organization and capped at limit hits.
|
||||
SearchDocumentBlocks(ctx context.Context, organizationID, query string, limit int) ([]searchgw.Block, error)
|
||||
}
|
||||
|
||||
// TreeNotifier publishes document-tree-change events so connected
|
||||
// clients can refresh their sidebar after assistant-driven creates,
|
||||
// deletes, moves, renames, or icon changes. The dochandler.Handler
|
||||
// satisfies this interface via its NotifyTreeChange method.
|
||||
type TreeNotifier interface {
|
||||
// NotifyTreeChange tells subscribers that the tree under
|
||||
// parentID (a null value means the root) changed in
|
||||
// organizationID. Implementations must be safe to call
|
||||
// concurrently.
|
||||
NotifyTreeChange(organizationID string, parentID null.Value[xid.ID])
|
||||
}
|
||||
|
||||
// Manager dispatches AI tool calls. One Manager is constructed per
|
||||
// session and is scoped to a single (organization, user) pair so
|
||||
// cross-org access is impossible by construction.
|
||||
type Manager struct {
|
||||
// log is scoped to the session's (org, user) and used to record
|
||||
// per-tool outcomes so we can diagnose AI loops without
|
||||
// re-running the conversation.
|
||||
log *slog.Logger
|
||||
|
||||
// db is the persistence used by read tools and the
|
||||
// non-content write tools (create/delete/move).
|
||||
db DB
|
||||
|
||||
// search is the full-text index behind search_documents.
|
||||
search Searcher
|
||||
|
||||
// liveedit is the live edit client for content mutations and
|
||||
// the rename/set-icon ops that must propagate to connected
|
||||
// editors.
|
||||
liveedit *liveedit.Client
|
||||
|
||||
// tree notifies tree-change subscribers after the assistant
|
||||
// mutates the document tree.
|
||||
tree TreeNotifier
|
||||
|
||||
// orgID scopes every tool call to one organization.
|
||||
orgID string
|
||||
|
||||
// userID identifies the user the assistant is acting for. Used
|
||||
// when a tool creates audit-relevant rows (the created_by
|
||||
// fields on a new document, for instance).
|
||||
userID string
|
||||
}
|
||||
|
||||
// NewManager constructs a Manager. Every dependency is required;
|
||||
// nil values surface as nil-pointer panics on the first tool call
|
||||
// rather than at startup, but in practice the cmd-level wiring
|
||||
// passes all of them.
|
||||
func NewManager(log *slog.Logger, db DB, search Searcher, liveedit *liveedit.Client, tree TreeNotifier, orgID, userID string) *Manager {
|
||||
return &Manager{
|
||||
log: log.With(
|
||||
"component", "assistant-tools",
|
||||
"org_id", orgID,
|
||||
"user_id", userID,
|
||||
),
|
||||
db: db,
|
||||
search: search,
|
||||
liveedit: liveedit,
|
||||
tree: tree,
|
||||
orgID: orgID,
|
||||
userID: userID,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute runs the named tool with the supplied JSON arguments and
|
||||
// returns the JSON tool_result the caller should hand back to
|
||||
// Anthropic. Errors from the tool (validation, persistence, RPC) are
|
||||
// returned as Go errors; the caller surfaces them as a structured
|
||||
// tool_result on the model's behalf.
|
||||
func (m *Manager) Execute(ctx context.Context, name Name, args json.RawMessage) (json.RawMessage, error) {
|
||||
switch name {
|
||||
case NameListDocuments:
|
||||
return m.listDocuments(ctx, args)
|
||||
case NameGetDocument:
|
||||
return m.getDocument(ctx, args)
|
||||
case NameReadDocumentSummary:
|
||||
return m.readDocumentSummary(ctx, args)
|
||||
case NameReadBlock:
|
||||
return m.readBlock(ctx, args)
|
||||
case NameSearchDocuments:
|
||||
return m.searchDocuments(ctx, args)
|
||||
case NameCreateDocument:
|
||||
return m.createDocument(ctx, args)
|
||||
case NameDeleteDocument:
|
||||
return m.deleteDocument(ctx, args)
|
||||
case NameRenameDocument:
|
||||
return m.renameDocument(ctx, args)
|
||||
case NameSetDocumentIcon:
|
||||
return m.setDocumentIcon(ctx, args)
|
||||
case NameMoveDocument:
|
||||
return m.moveDocument(ctx, args)
|
||||
case NameInsertBlock:
|
||||
return m.insertBlock(ctx, args)
|
||||
case NameAppendBlock:
|
||||
return m.appendBlock(ctx, args)
|
||||
case NamePrependBlock:
|
||||
return m.prependBlock(ctx, args)
|
||||
case NameReplaceBlock:
|
||||
return m.replaceBlock(ctx, args)
|
||||
case NameUpdateBlockText:
|
||||
return m.updateBlockText(ctx, args)
|
||||
case NameUpdateBlockAttrs:
|
||||
return m.updateBlockAttrs(ctx, args)
|
||||
case NameDeleteBlock:
|
||||
return m.deleteBlock(ctx, args)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown tool: %s", name)
|
||||
}
|
||||
477
server/core/internal/assistant/tools/writes.go
Normal file
477
server/core/internal/assistant/tools/writes.go
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/oxynote/heimdall/internal/document"
|
||||
"github.com/oxynote/heimdall/internal/document/aiblock"
|
||||
"github.com/oxynote/heimdall/internal/document/liveedit"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// docRef wraps the (documentID, branchID) pair the liveedit client
|
||||
// needs to address a live Y.Doc. The branch is resolved to the
|
||||
// document's default branch — multi-branch editing is out of scope
|
||||
// for the assistant.
|
||||
type docRef struct {
|
||||
DocumentID string
|
||||
BranchID string
|
||||
OrgID string
|
||||
}
|
||||
|
||||
// resolveDoc loads the default branch of the given document and
|
||||
// returns the ids the liveedit client needs. The lookup also acts
|
||||
// as the cross-org safety check — db.FetchDocument scopes by orgID
|
||||
// so a docID from another organisation surfaces as NotFound.
|
||||
func (m *Manager) resolveDoc(ctx context.Context, documentID string) (docRef, error) {
|
||||
docID, err := xid.FromString(documentID)
|
||||
if err != nil {
|
||||
return docRef{}, fmt.Errorf("document_id is not a valid xid: %w", err)
|
||||
}
|
||||
|
||||
doc, err := m.db.FetchDocument(ctx, docID, m.orgID, document.DefaultBranch)
|
||||
if err != nil {
|
||||
return docRef{}, fmt.Errorf("fetch document: %w", err)
|
||||
}
|
||||
|
||||
return docRef{
|
||||
DocumentID: doc.ID.String(),
|
||||
BranchID: doc.BranchID.String(),
|
||||
OrgID: doc.OrganizationID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) createDocument(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
ParentID string `json:"parent_id"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("create_document: invalid input: %w", err)
|
||||
}
|
||||
|
||||
if in.Name == "" {
|
||||
return nil, errors.New("create_document: name is required")
|
||||
}
|
||||
|
||||
icon := in.Icon
|
||||
if icon == "" {
|
||||
icon = "lucide:file"
|
||||
}
|
||||
|
||||
var parentID null.Value[xid.ID]
|
||||
if in.ParentID != "" {
|
||||
pid, err := xid.FromString(in.ParentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create_document: parent_id is not a valid xid: %w", err)
|
||||
}
|
||||
|
||||
if err := m.db.CheckDocumentExists(ctx, pid, m.orgID); err != nil {
|
||||
return nil, fmt.Errorf("create_document: parent not found: %w", err)
|
||||
}
|
||||
|
||||
parentID = null.ValueFrom(pid)
|
||||
}
|
||||
|
||||
doc := document.NewDocument(document.CreateInput{
|
||||
Name: in.Name,
|
||||
Icon: icon,
|
||||
ParentID: parentID,
|
||||
}, m.orgID, m.userID)
|
||||
|
||||
if err := m.db.InsertDocument(ctx, doc); err != nil {
|
||||
return nil, fmt.Errorf("create_document: insert: %w", err)
|
||||
}
|
||||
|
||||
// Mirror the HTTP create path: whoever asked for the document
|
||||
// becomes its first maintainer so they own it from the start.
|
||||
if err := m.db.UpsertDocumentMaintainers(ctx, doc.ID, m.orgID, []string{m.userID}); err != nil {
|
||||
return nil, fmt.Errorf("create_document: upsert maintainers: %w", err)
|
||||
}
|
||||
|
||||
m.notifyTreeChange(doc.ParentID)
|
||||
|
||||
return marshalResult(struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
BranchID string `json:"branch_id"`
|
||||
}{
|
||||
DocumentID: doc.ID.String(),
|
||||
BranchID: doc.BranchID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) deleteDocument(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("delete_document: invalid input: %w", err)
|
||||
}
|
||||
|
||||
docID, err := xid.FromString(in.DocumentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("delete_document: document_id is not a valid xid: %w", err)
|
||||
}
|
||||
|
||||
// Capture the parent before the row goes away so we can scope
|
||||
// the tree-change notification to the affected subtree.
|
||||
var parentID null.Value[xid.ID]
|
||||
|
||||
if doc, ferr := m.db.FetchDocument(ctx, docID, m.orgID, document.DefaultBranch); ferr == nil && doc != nil {
|
||||
parentID = doc.ParentID
|
||||
}
|
||||
|
||||
if err := m.db.DeleteDocument(ctx, docID, m.orgID); err != nil {
|
||||
return nil, fmt.Errorf("delete_document: delete: %w", err)
|
||||
}
|
||||
|
||||
m.notifyTreeChange(parentID)
|
||||
|
||||
return marshalResult(struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}{
|
||||
DocumentID: docID.String(),
|
||||
Deleted: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) renameDocument(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("rename_document: invalid input: %w", err)
|
||||
}
|
||||
|
||||
if in.Name == "" {
|
||||
return nil, errors.New("rename_document: name is required")
|
||||
}
|
||||
|
||||
out, err := m.applyEdit(ctx, in.DocumentID, []liveedit.Operation{liveedit.SetName(in.Name)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.notifyTreeChangeForDocument(ctx, in.DocumentID)
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Manager) setDocumentIcon(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("set_document_icon: invalid input: %w", err)
|
||||
}
|
||||
|
||||
if in.Icon == "" {
|
||||
return nil, errors.New("set_document_icon: icon is required")
|
||||
}
|
||||
|
||||
out, err := m.applyEdit(ctx, in.DocumentID, []liveedit.Operation{liveedit.SetIcon(in.Icon)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.notifyTreeChangeForDocument(ctx, in.DocumentID)
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Manager) moveDocument(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
NewParentID string `json:"new_parent_id"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("move_document: invalid input: %w", err)
|
||||
}
|
||||
|
||||
docID, err := xid.FromString(in.DocumentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("move_document: document_id is not a valid xid: %w", err)
|
||||
}
|
||||
|
||||
doc, err := m.db.FetchDocument(ctx, docID, m.orgID, document.DefaultBranch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("move_document: document not found: %w", err)
|
||||
}
|
||||
|
||||
oldParent := doc.ParentID
|
||||
|
||||
var newParent null.Value[xid.ID]
|
||||
if in.NewParentID != "" {
|
||||
pid, err := xid.FromString(in.NewParentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("move_document: new_parent_id is not a valid xid: %w", err)
|
||||
}
|
||||
|
||||
if err := m.db.CheckDocumentExists(ctx, pid, m.orgID); err != nil {
|
||||
return nil, fmt.Errorf("move_document: new parent not found: %w", err)
|
||||
}
|
||||
|
||||
newParent = null.ValueFrom(pid)
|
||||
}
|
||||
|
||||
if err := m.db.UpdateDocumentParentID(ctx, docID, newParent, m.orgID); err != nil {
|
||||
return nil, fmt.Errorf("move_document: update: %w", err)
|
||||
}
|
||||
|
||||
// Both the source and destination subtrees changed shape; tell
|
||||
// subscribers about each. When the move is within the same
|
||||
// parent the second call is a duplicate but harmless.
|
||||
m.notifyTreeChange(oldParent)
|
||||
|
||||
if oldParent != newParent {
|
||||
m.notifyTreeChange(newParent)
|
||||
}
|
||||
|
||||
out := struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
NewParentID string `json:"new_parent_id,omitempty"`
|
||||
}{
|
||||
DocumentID: docID.String(),
|
||||
}
|
||||
|
||||
if newParent.Valid {
|
||||
out.NewParentID = newParent.V.String()
|
||||
}
|
||||
|
||||
return marshalResult(out)
|
||||
}
|
||||
|
||||
func (m *Manager) insertBlock(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
ReferenceBlockUID string `json:"reference_block_uid"`
|
||||
Position string `json:"position"`
|
||||
Block aiblock.Block `json:"block"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("insert_block: invalid input: %w", err)
|
||||
}
|
||||
|
||||
if in.ReferenceBlockUID == "" {
|
||||
return nil, errors.New("insert_block: reference_block_uid is required")
|
||||
}
|
||||
|
||||
if err := aiblock.Validate(in.Block); err != nil {
|
||||
return nil, fmt.Errorf("insert_block: %w", err)
|
||||
}
|
||||
|
||||
var op liveedit.Operation
|
||||
switch in.Position {
|
||||
case "before":
|
||||
op = liveedit.InsertBefore(in.ReferenceBlockUID, in.Block)
|
||||
case "after":
|
||||
op = liveedit.InsertAfter(in.ReferenceBlockUID, in.Block)
|
||||
default:
|
||||
return nil, fmt.Errorf("insert_block: position must be \"before\" or \"after\", got %q", in.Position)
|
||||
}
|
||||
|
||||
return m.applyEdit(ctx, in.DocumentID, []liveedit.Operation{op})
|
||||
}
|
||||
|
||||
func (m *Manager) appendBlock(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
Block aiblock.Block `json:"block"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("append_block: invalid input: %w", err)
|
||||
}
|
||||
|
||||
if err := aiblock.ValidateAsRoot(in.Block); err != nil {
|
||||
return nil, fmt.Errorf("append_block: %w", err)
|
||||
}
|
||||
|
||||
return m.applyEdit(ctx, in.DocumentID, []liveedit.Operation{liveedit.Append(in.Block)})
|
||||
}
|
||||
|
||||
func (m *Manager) prependBlock(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
Block aiblock.Block `json:"block"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("prepend_block: invalid input: %w", err)
|
||||
}
|
||||
|
||||
if err := aiblock.ValidateAsRoot(in.Block); err != nil {
|
||||
return nil, fmt.Errorf("prepend_block: %w", err)
|
||||
}
|
||||
|
||||
return m.applyEdit(ctx, in.DocumentID, []liveedit.Operation{liveedit.Prepend(in.Block)})
|
||||
}
|
||||
|
||||
func (m *Manager) replaceBlock(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
BlockUID string `json:"block_uid"`
|
||||
Block aiblock.Block `json:"block"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("replace_block: invalid input: %w", err)
|
||||
}
|
||||
|
||||
if in.BlockUID == "" {
|
||||
return nil, errors.New("replace_block: block_uid is required")
|
||||
}
|
||||
|
||||
if err := aiblock.Validate(in.Block); err != nil {
|
||||
return nil, fmt.Errorf("replace_block: %w", err)
|
||||
}
|
||||
|
||||
return m.applyEdit(ctx, in.DocumentID, []liveedit.Operation{liveedit.Replace(in.BlockUID, in.Block)})
|
||||
}
|
||||
|
||||
func (m *Manager) updateBlockText(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
BlockUID string `json:"block_uid"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("update_block_text: invalid input: %w", err)
|
||||
}
|
||||
|
||||
if in.BlockUID == "" {
|
||||
return nil, errors.New("update_block_text: block_uid is required")
|
||||
}
|
||||
|
||||
return m.applyEdit(ctx, in.DocumentID, []liveedit.Operation{liveedit.UpdateText(in.BlockUID, in.Text)})
|
||||
}
|
||||
|
||||
func (m *Manager) updateBlockAttrs(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
BlockUID string `json:"block_uid"`
|
||||
Attrs map[string]any `json:"attrs"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("update_block_attrs: invalid input: %w", err)
|
||||
}
|
||||
|
||||
if in.BlockUID == "" {
|
||||
return nil, errors.New("update_block_attrs: block_uid is required")
|
||||
}
|
||||
|
||||
if len(in.Attrs) == 0 {
|
||||
return nil, errors.New("update_block_attrs: attrs must not be empty")
|
||||
}
|
||||
|
||||
return m.applyEdit(ctx, in.DocumentID, []liveedit.Operation{liveedit.UpdateAttrs(in.BlockUID, in.Attrs)})
|
||||
}
|
||||
|
||||
func (m *Manager) deleteBlock(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
BlockUID string `json:"block_uid"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, fmt.Errorf("delete_block: invalid input: %w", err)
|
||||
}
|
||||
|
||||
if in.BlockUID == "" {
|
||||
return nil, errors.New("delete_block: block_uid is required")
|
||||
}
|
||||
|
||||
return m.applyEdit(ctx, in.DocumentID, []liveedit.Operation{liveedit.Delete(in.BlockUID)})
|
||||
}
|
||||
|
||||
// notifyTreeChange invokes the tree notifier when one is configured.
|
||||
// Safe to call from any tool — a nil notifier silently no-ops so
|
||||
// tests that don't wire one don't trip.
|
||||
func (m *Manager) notifyTreeChange(parentID null.Value[xid.ID]) {
|
||||
if m.tree == nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.tree.NotifyTreeChange(m.orgID, parentID)
|
||||
}
|
||||
|
||||
// notifyTreeChangeForDocument looks up the document's current parent
|
||||
// and fires a tree-change for that parent. Used by rename/icon ops
|
||||
// which don't carry a parent in their args. Failures (e.g. doc
|
||||
// fetched after delete) silently skip the notification.
|
||||
func (m *Manager) notifyTreeChangeForDocument(ctx context.Context, documentID string) {
|
||||
docID, err := xid.FromString(documentID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := m.db.FetchDocument(ctx, docID, m.orgID, document.DefaultBranch)
|
||||
if err != nil || doc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.notifyTreeChange(doc.ParentID)
|
||||
}
|
||||
|
||||
// applyEdit is the shared tail of every content-mutating write
|
||||
// tool: it resolves the document to a (documentID, branchID) pair,
|
||||
// ships the operation batch to Node, and surfaces the per-op
|
||||
// result. Outcomes are logged so partial failures on the Node side
|
||||
// (uid not found, malformed block) are visible without re-running
|
||||
// the conversation.
|
||||
func (m *Manager) applyEdit(ctx context.Context, documentID string, ops []liveedit.Operation) (json.RawMessage, error) {
|
||||
ref, err := m.resolveDoc(ctx, documentID)
|
||||
if err != nil {
|
||||
m.log.Warn("liveedit resolve failed",
|
||||
"document_id", documentID,
|
||||
"error", err.Error(),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := m.liveedit.Apply(ctx, ref.DocumentID, ref.BranchID, ops)
|
||||
if err != nil {
|
||||
m.log.Error("liveedit apply failed",
|
||||
"document_id", ref.DocumentID,
|
||||
"branch_id", ref.BranchID,
|
||||
"op_count", len(ops),
|
||||
"error", err.Error(),
|
||||
)
|
||||
|
||||
return nil, fmt.Errorf("apply edit: %w", err)
|
||||
}
|
||||
|
||||
if len(res.Errors) > 0 {
|
||||
m.log.Warn("liveedit partial failure",
|
||||
"document_id", ref.DocumentID,
|
||||
"branch_id", ref.BranchID,
|
||||
"applied", res.Applied,
|
||||
"errors", res.Errors,
|
||||
)
|
||||
} else {
|
||||
m.log.Debug("liveedit applied",
|
||||
"document_id", ref.DocumentID,
|
||||
"branch_id", ref.BranchID,
|
||||
"applied", res.Applied,
|
||||
)
|
||||
}
|
||||
|
||||
return marshalResult(res)
|
||||
}
|
||||
110
server/core/internal/buildinfo/buildinfo.go
Normal file
110
server/core/internal/buildinfo/buildinfo.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// Package buildinfo provides general information about the exec build.
|
||||
package buildinfo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/blang/semver/v4"
|
||||
"github.com/oxynote/purse/util/logutil"
|
||||
"github.com/oxynote/purse/util/timeutil"
|
||||
)
|
||||
|
||||
var (
|
||||
_name = "heimdall_gate"
|
||||
_version = "0.0.0-dev"
|
||||
_commit = "0000000"
|
||||
_timestampString = "2006-12-09T19:00:00Z"
|
||||
|
||||
_info = BuildInfo{
|
||||
Env: "dev",
|
||||
Name: _name,
|
||||
Commit: _commit,
|
||||
}
|
||||
)
|
||||
|
||||
func init() { //nolint:gochecknoinits // we need to prepare build info
|
||||
logutil.ShowCritical = IsDevEnv()
|
||||
|
||||
// only strings can be inserted via ldflags which means
|
||||
// some information needs to be parsed
|
||||
|
||||
_info.Version, _ = semver.ParseTolerant(_version)
|
||||
_info.Timestamp, _ = timeutil.Parse(_timestampString)
|
||||
}
|
||||
|
||||
// BuildInfo contains information about the executable.
|
||||
type BuildInfo struct {
|
||||
// Env specifies the execution environment type.
|
||||
Env string `json:"env"`
|
||||
|
||||
// Name specifies the name of the executable.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Version specifies the version of the executable.
|
||||
Version semver.Version `json:"version"`
|
||||
|
||||
// Commit specifies the hash of the commit that was used to build
|
||||
// the executable.
|
||||
Commit string `json:"commit"`
|
||||
|
||||
// Timestamp specifies the build timestamp of the executable.
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// String turns build information into a string.
|
||||
func (b BuildInfo) String() string {
|
||||
return fmt.Sprintf(
|
||||
"%s %s (%s; %s)",
|
||||
b.Name,
|
||||
b.Version,
|
||||
b.Commit,
|
||||
b.FormattedTimestamp(),
|
||||
)
|
||||
}
|
||||
|
||||
// VersionName extracts the name of the version from semver build
|
||||
// metadata.
|
||||
func (b BuildInfo) VersionName() string {
|
||||
if len(b.Version.Build) > 0 {
|
||||
return b.Version.Build[0]
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// PlainVersion returns a version string without metadata.
|
||||
func (b BuildInfo) PlainVersion() string {
|
||||
v := b.Version
|
||||
v.Build = nil
|
||||
|
||||
return v.String()
|
||||
}
|
||||
|
||||
// FormattedTimestamp returns a formatted timestamp's string.
|
||||
func (b BuildInfo) FormattedTimestamp() string {
|
||||
return b.Timestamp.Format(timeutil.ReadableFormat)
|
||||
}
|
||||
|
||||
// EnvName combines the provided key with executable name.
|
||||
func EnvName(key string) string {
|
||||
return fmt.Sprintf("%s_%s", strings.ToUpper(_name), key)
|
||||
}
|
||||
|
||||
// Getenv retrieves an environment variable value by the provided key
|
||||
// and name combination.
|
||||
func Getenv(key string) string {
|
||||
return os.Getenv(EnvName(key))
|
||||
}
|
||||
|
||||
// Full returns all information about the executable.
|
||||
func Full() BuildInfo {
|
||||
return _info
|
||||
}
|
||||
|
||||
// IsDevEnv determines whether the environment is dev.
|
||||
func IsDevEnv() bool {
|
||||
return _info.Env == "dev"
|
||||
}
|
||||
384
server/core/internal/connector/datasourcehandler/handler.go
Normal file
384
server/core/internal/connector/datasourcehandler/handler.go
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
package datasourcehandler
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/oxynote/heimdall/internal/datasource/connector"
|
||||
"github.com/oxynote/heimdall/internal/datasource/processor"
|
||||
"github.com/oxynote/purse/http/httpserver"
|
||||
)
|
||||
|
||||
// Handler holds dependencies required for data source operations.
|
||||
type Handler struct {
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewHandler creates a new handler instance with the provided logger.
|
||||
func NewHandler(log *slog.Logger) *Handler {
|
||||
return &Handler{
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataSourceConnection tests the connection to a data source via the connector.
|
||||
func (h *Handler) TestDataSourceConnection(w http.ResponseWriter, r *http.Request) {
|
||||
var req connector.TestConnectionRequest
|
||||
|
||||
if err := httpserver.DecodeJSON(r, &req); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
cs, err := req.Runner.TestConnection(r.Context())
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpserver.Respond(h.log, w, connector.Response{
|
||||
Status: cs,
|
||||
}, http.StatusOK)
|
||||
}
|
||||
|
||||
// QueryPrometheusDataSource handles executing a query against a Prometheus data source.
|
||||
func (h *Handler) QueryPrometheusDataSource(w http.ResponseWriter, r *http.Request) {
|
||||
var req connector.PrometheusQueryRequest
|
||||
|
||||
if err := httpserver.DecodeJSON(r, &req); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
prom, cs, err := req.Runner.Prometheus(r.Context())
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if cs != processor.ConnectionStatusSuccess {
|
||||
httpserver.Respond(h.log, w, connector.Response{
|
||||
Status: cs,
|
||||
}, http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
result, err := prom.QueryRange(r.Context(), req.Query, *req.TimeRange)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := connector.NewResponse(cs, result)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpserver.Respond(h.log, w, resp, http.StatusOK)
|
||||
}
|
||||
|
||||
// QueryPostgreSQLDataSource handles executing a SQL query against a PostgreSQL data source.
|
||||
func (h *Handler) QueryPostgreSQLDataSource(w http.ResponseWriter, r *http.Request) {
|
||||
var req connector.PostgreSQLQueryRequest
|
||||
|
||||
if err := httpserver.DecodeJSON(r, &req); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
pg, cs, err := req.Runner.PostgreSQL(r.Context())
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if cs != processor.ConnectionStatusSuccess {
|
||||
httpserver.Respond(h.log, w, connector.Response{
|
||||
Status: cs,
|
||||
}, http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
result, err := pg.Query(r.Context(), req.Query, *req.TimeRange)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := connector.NewResponse(cs, result)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpserver.Respond(h.log, w, resp, http.StatusOK)
|
||||
}
|
||||
|
||||
// QueryMySQLDataSource handles executing a SQL query against a MySQL data source.
|
||||
func (h *Handler) QueryMySQLDataSource(w http.ResponseWriter, r *http.Request) {
|
||||
var req connector.MySQLQueryRequest
|
||||
|
||||
if err := httpserver.DecodeJSON(r, &req); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
mdb, cs, err := req.Runner.MySQL(r.Context())
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if cs != processor.ConnectionStatusSuccess {
|
||||
httpserver.Respond(h.log, w, connector.Response{
|
||||
Status: cs,
|
||||
}, http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
result, err := mdb.Query(r.Context(), req.Query, *req.TimeRange)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := connector.NewResponse(cs, result)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpserver.Respond(h.log, w, resp, http.StatusOK)
|
||||
}
|
||||
|
||||
// FetchSQLQueryLabels handles executing a query with LIMIT 1 and returning string column labels.
|
||||
func (h *Handler) FetchSQLQueryLabels(w http.ResponseWriter, r *http.Request) {
|
||||
var req connector.SQLQueryLabelsRequest
|
||||
|
||||
if err := httpserver.DecodeJSON(r, &req); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
sqlDS, cs, err := req.Runner.SQL(r.Context())
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if cs != processor.ConnectionStatusSuccess {
|
||||
httpserver.Respond(h.log, w, connector.Response{
|
||||
Status: cs,
|
||||
}, http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
result, err := sqlDS.QueryLabels(r.Context(), req.Query, *req.TimeRange)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := connector.NewResponse(cs, result)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpserver.Respond(h.log, w, resp, http.StatusOK)
|
||||
}
|
||||
|
||||
// FetchSQLMetadata handles retrieving all tables and columns from a SQL data source.
|
||||
func (h *Handler) FetchSQLMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
var req connector.SQLMetadataRequest
|
||||
|
||||
if err := httpserver.DecodeJSON(r, &req); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
sql, cs, err := req.Runner.SQL(r.Context())
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if cs != processor.ConnectionStatusSuccess {
|
||||
httpserver.Respond(h.log, w, connector.Response{
|
||||
Status: cs,
|
||||
}, http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
result, err := sql.Metadata(r.Context())
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := connector.NewResponse(cs, result)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpserver.Respond(h.log, w, resp, http.StatusOK)
|
||||
}
|
||||
|
||||
// FetchPrometheusDataSourceMetadata handles retrieving metadata from a Prometheus data source.
|
||||
func (h *Handler) FetchPrometheusDataSourceMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
var req connector.PrometheusMetadataRequest
|
||||
|
||||
if err := httpserver.DecodeJSON(r, &req); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
prom, cs, err := req.Runner.Prometheus(r.Context())
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if cs != processor.ConnectionStatusSuccess {
|
||||
httpserver.Respond(h.log, w, connector.Response{
|
||||
Status: cs,
|
||||
}, http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
result, err := prom.Metadata(r.Context())
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := connector.NewResponse(cs, result)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpserver.Respond(h.log, w, resp, http.StatusOK)
|
||||
}
|
||||
|
||||
// FetchPrometheusDataSourceLabelNames handles retrieving label names from a Prometheus data source.
|
||||
func (h *Handler) FetchPrometheusDataSourceLabelNames(w http.ResponseWriter, r *http.Request) {
|
||||
var req connector.PrometheusLabelNamesRequest
|
||||
|
||||
if err := httpserver.DecodeJSON(r, &req); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
prom, cs, err := req.Runner.Prometheus(r.Context())
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if cs != processor.ConnectionStatusSuccess {
|
||||
httpserver.Respond(h.log, w, connector.Response{
|
||||
Status: cs,
|
||||
}, http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
result, err := prom.LabelNames(r.Context(), req.Matchers, *req.TimeRange)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := connector.NewResponse(cs, result)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpserver.Respond(h.log, w, resp, http.StatusOK)
|
||||
}
|
||||
|
||||
// FetchPrometheusDataSourceLabelValues handles retrieving label values from a Prometheus data source.
|
||||
func (h *Handler) FetchPrometheusDataSourceLabelValues(w http.ResponseWriter, r *http.Request) {
|
||||
var req connector.PrometheusLabelValuesRequest
|
||||
|
||||
if err := httpserver.DecodeJSON(r, &req); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
prom, cs, err := req.Runner.Prometheus(r.Context())
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if cs != processor.ConnectionStatusSuccess {
|
||||
httpserver.Respond(h.log, w, connector.Response{
|
||||
Status: cs,
|
||||
}, http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
result, err := prom.LabelValues(r.Context(), req.Label, req.Matchers, *req.TimeRange)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := connector.NewResponse(cs, result)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpserver.Respond(h.log, w, resp, http.StatusOK)
|
||||
}
|
||||
|
||||
// FetchPrometheusDataSourceSeries handles retrieving series from a Prometheus data source.
|
||||
func (h *Handler) FetchPrometheusDataSourceSeries(w http.ResponseWriter, r *http.Request) {
|
||||
var req connector.PrometheusSeriesRequest
|
||||
|
||||
if err := httpserver.DecodeJSON(r, &req); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
prom, cs, err := req.Runner.Prometheus(r.Context())
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if cs != processor.ConnectionStatusSuccess {
|
||||
httpserver.Respond(h.log, w, connector.Response{
|
||||
Status: cs,
|
||||
}, http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
result, err := prom.Series(r.Context(), req.Matchers, *req.TimeRange)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := connector.NewResponse(cs, result)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpserver.Respond(h.log, w, resp, http.StatusOK)
|
||||
}
|
||||
35
server/core/internal/connector/router.go
Normal file
35
server/core/internal/connector/router.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package connector
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// router prepares all main application routes.
|
||||
func (s *Server) router() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Route("/api/data-sources/", func(sr chi.Router) {
|
||||
sr.Post("/connection", s.handlers.datasource.TestDataSourceConnection)
|
||||
sr.Route("/prometheus", func(ssr chi.Router) {
|
||||
ssr.Post("/query", s.handlers.datasource.QueryPrometheusDataSource)
|
||||
ssr.Post("/metadata", s.handlers.datasource.FetchPrometheusDataSourceMetadata)
|
||||
ssr.Post("/series", s.handlers.datasource.FetchPrometheusDataSourceSeries)
|
||||
ssr.Route("/labels", func(ssssr chi.Router) {
|
||||
ssssr.Post("/", s.handlers.datasource.FetchPrometheusDataSourceLabelNames)
|
||||
ssssr.Post("/values", s.handlers.datasource.FetchPrometheusDataSourceLabelValues)
|
||||
})
|
||||
})
|
||||
sr.Route("/sql", func(ssr chi.Router) {
|
||||
ssr.Post("/metadata", s.handlers.datasource.FetchSQLMetadata)
|
||||
ssr.Post("/query-labels", s.handlers.datasource.FetchSQLQueryLabels)
|
||||
})
|
||||
sr.Route("/postgresql", func(ssr chi.Router) {
|
||||
ssr.Post("/query", s.handlers.datasource.QueryPostgreSQLDataSource)
|
||||
})
|
||||
sr.Route("/mysql", func(ssr chi.Router) {
|
||||
ssr.Post("/query", s.handlers.datasource.QueryMySQLDataSource)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
118
server/core/internal/connector/server.go
Normal file
118
server/core/internal/connector/server.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// Package connector implements a server that handles remote connections to the system.
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/oxynote/heimdall/internal/connector/datasourcehandler"
|
||||
"github.com/oxynote/purse/util/logutil"
|
||||
"github.com/oxynote/purse/util/metricutil"
|
||||
)
|
||||
|
||||
// Options holds all server options.
|
||||
type Options struct {
|
||||
// Port specifies which port should be used by the server.
|
||||
Port uint
|
||||
|
||||
// Secure enables additional security-related features.
|
||||
// Note that most of the security settings should be handled
|
||||
// by a reverse proxy, this option just updates a few internal
|
||||
// processes all of which expect a reverse proxy to be working as
|
||||
// well.
|
||||
Secure bool
|
||||
}
|
||||
|
||||
// validate validates remote server options.
|
||||
func (o Options) validate() error {
|
||||
if o.Port != 0 && o.Port < 1024 {
|
||||
return errors.New("invalid port")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Server handles remote connections to the system.
|
||||
type Server struct {
|
||||
log *slog.Logger
|
||||
http *http.Server
|
||||
fc metricutil.Factory
|
||||
|
||||
handlers struct {
|
||||
datasource *datasourcehandler.Handler
|
||||
}
|
||||
|
||||
opts Options
|
||||
}
|
||||
|
||||
// NewServer creates a fresh instance of a server.
|
||||
// If central auth is nil, the default users are enabled.
|
||||
func NewServer(
|
||||
ctx context.Context,
|
||||
log *slog.Logger,
|
||||
opts Options,
|
||||
fc metricutil.Factory,
|
||||
) (*Server, error) {
|
||||
if err := opts.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
srv := &Server{
|
||||
log: log.With("component", "server"),
|
||||
opts: opts,
|
||||
fc: fc,
|
||||
}
|
||||
|
||||
srv.handlers.datasource = datasourcehandler.NewHandler(log)
|
||||
|
||||
srv.http = &http.Server{
|
||||
ReadTimeout: time.Minute,
|
||||
ReadHeaderTimeout: time.Minute,
|
||||
Addr: fmt.Sprintf(":%d", opts.Port),
|
||||
Handler: srv.router(),
|
||||
ErrorLog: logutil.NewDebugWriter(log, "EOF").StdLogger(),
|
||||
}
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Listen starts listening for incoming connections.
|
||||
func (s *Server) Listen() {
|
||||
s.log.Info("listening", slog.String("address", s.Address()))
|
||||
|
||||
err := s.http.ListenAndServe()
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
s.log.Error("cannot terminate gracefully", slog.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
// Address returns the address of the server.
|
||||
func (s *Server) Address() string {
|
||||
host := "localhost"
|
||||
if s.http.Addr != ":80" {
|
||||
host += s.http.Addr // port can be random
|
||||
}
|
||||
|
||||
addr := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: host,
|
||||
}
|
||||
|
||||
return addr.String()
|
||||
}
|
||||
|
||||
// Close performs clean up operations.
|
||||
func (s *Server) Close() error {
|
||||
if err := s.http.Shutdown(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.log.Debug("closed")
|
||||
|
||||
return nil
|
||||
}
|
||||
98
server/core/internal/datasource/connector/client.go
Normal file
98
server/core/internal/datasource/connector/client.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Package connector provides a client for communicating with the connector service.
|
||||
package connector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/oxynote/purse/util/errutil"
|
||||
|
||||
"github.com/oxynote/heimdall/internal/datasource"
|
||||
"github.com/oxynote/heimdall/internal/datasource/processor"
|
||||
)
|
||||
|
||||
// Client communicates with the connector service for data source operations.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new connector client.
|
||||
func NewClient(baseURL string, httpClient *http.Client) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnection tests the connection to a data source via the connector.
|
||||
func (c *Client) TestConnection(ctx context.Context, ds datasource.DataSource) (processor.ConnectionStatus, error) {
|
||||
resp, err := c.sendRequest(ctx,
|
||||
"/api/data-sources/connection",
|
||||
NewTestConnectionRequest(
|
||||
datasource.NewRunner(ds),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return resp.Status, nil
|
||||
}
|
||||
|
||||
// sendRequest sends a request to the connector service and returns the response.
|
||||
func (c *Client) sendRequest(ctx context.Context, path string, body any) (*Response, error) {
|
||||
rawBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshaling connector request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(rawBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating connector request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
httpResp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error sending connector request: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
respBody, err := io.ReadAll(httpResp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading connector error response: %w", err)
|
||||
}
|
||||
|
||||
var errResp struct {
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
err = json.Unmarshal(respBody, &errResp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decoding connector error response: %w", err)
|
||||
}
|
||||
|
||||
if errResp.Code == "" {
|
||||
errResp.Code = "unknown_error"
|
||||
errResp.Message = "An unknown error occurred while processing the connector request."
|
||||
}
|
||||
|
||||
return nil, errutil.New(httpResp.StatusCode, errResp.Code, "%s", errResp.Message)
|
||||
}
|
||||
|
||||
var resp Response
|
||||
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("error decoding connector response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
37
server/core/internal/datasource/connector/mysql.go
Normal file
37
server/core/internal/datasource/connector/mysql.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/oxynote/heimdall/internal/datasource"
|
||||
"github.com/oxynote/heimdall/internal/datasource/processor"
|
||||
)
|
||||
|
||||
// MySQLQuery executes a SQL query against a MySQL data source via the connector.
|
||||
func (c *Client) MySQLQuery(ctx context.Context, ds datasource.DataSource, query string, tr processor.TimeRange) (processor.ConnectionStatus, *processor.MySQLQueryResult, error) {
|
||||
resp, err := c.sendRequest(ctx,
|
||||
"/api/data-sources/mysql/query",
|
||||
NewMySQLQueryRequest(
|
||||
datasource.NewRunner(ds),
|
||||
query,
|
||||
&tr,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if len(resp.Result) == 0 {
|
||||
return resp.Status, nil, nil
|
||||
}
|
||||
|
||||
var result processor.MySQLQueryResult
|
||||
|
||||
if err := json.Unmarshal(resp.Result, &result); err != nil {
|
||||
return "", nil, fmt.Errorf("error decoding mysql query result: %w", err)
|
||||
}
|
||||
|
||||
return resp.Status, &result, nil
|
||||
}
|
||||
37
server/core/internal/datasource/connector/postgresql.go
Normal file
37
server/core/internal/datasource/connector/postgresql.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/oxynote/heimdall/internal/datasource"
|
||||
"github.com/oxynote/heimdall/internal/datasource/processor"
|
||||
)
|
||||
|
||||
// PostgreSQLQuery executes a SQL query via the connector.
|
||||
func (c *Client) PostgreSQLQuery(ctx context.Context, ds datasource.DataSource, query string, tr processor.TimeRange) (processor.ConnectionStatus, *processor.PostgreSQLQueryResult, error) {
|
||||
resp, err := c.sendRequest(ctx,
|
||||
"/api/data-sources/postgresql/query",
|
||||
NewPostgreSQLQueryRequest(
|
||||
datasource.NewRunner(ds),
|
||||
query,
|
||||
&tr,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if len(resp.Result) == 0 {
|
||||
return resp.Status, nil, nil
|
||||
}
|
||||
|
||||
var result processor.PostgreSQLQueryResult
|
||||
|
||||
if err := json.Unmarshal(resp.Result, &result); err != nil {
|
||||
return "", nil, fmt.Errorf("error decoding postgresql query result: %w", err)
|
||||
}
|
||||
|
||||
return resp.Status, &result, nil
|
||||
}
|
||||
144
server/core/internal/datasource/connector/prometheus.go
Normal file
144
server/core/internal/datasource/connector/prometheus.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/oxynote/heimdall/internal/datasource"
|
||||
"github.com/oxynote/heimdall/internal/datasource/processor"
|
||||
)
|
||||
|
||||
// PrometheusQuery executes a Prometheus query via the connector.
|
||||
func (c *Client) PrometheusQuery(ctx context.Context, ds datasource.DataSource, query string, tr processor.TimeRange) (processor.ConnectionStatus, *processor.PrometheusQueryResult, error) {
|
||||
resp, err := c.sendRequest(ctx,
|
||||
"/api/data-sources/prometheus/query",
|
||||
NewPrometheusQueryRequest(
|
||||
datasource.NewRunner(ds),
|
||||
query,
|
||||
&tr,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if len(resp.Result) == 0 {
|
||||
return resp.Status, nil, nil
|
||||
}
|
||||
|
||||
var result processor.PrometheusQueryResult
|
||||
|
||||
if err := json.Unmarshal(resp.Result, &result); err != nil {
|
||||
return "", nil, fmt.Errorf("error decoding prometheus query result: %w", err)
|
||||
}
|
||||
|
||||
return resp.Status, &result, nil
|
||||
}
|
||||
|
||||
// PrometheusMetadata retrieves Prometheus metadata via the connector.
|
||||
func (c *Client) PrometheusMetadata(ctx context.Context, ds datasource.DataSource) (processor.ConnectionStatus, *processor.PrometheusMetadataResult, error) {
|
||||
resp, err := c.sendRequest(ctx,
|
||||
"/api/data-sources/prometheus/metadata",
|
||||
NewPrometheusMetadataRequest(
|
||||
datasource.NewRunner(ds),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if len(resp.Result) == 0 {
|
||||
return resp.Status, nil, nil
|
||||
}
|
||||
|
||||
var result processor.PrometheusMetadataResult
|
||||
|
||||
if err := json.Unmarshal(resp.Result, &result); err != nil {
|
||||
return "", nil, fmt.Errorf("error decoding prometheus metadata result: %w", err)
|
||||
}
|
||||
|
||||
return resp.Status, &result, nil
|
||||
}
|
||||
|
||||
// PrometheusLabelNames retrieves Prometheus label names via the connector.
|
||||
func (c *Client) PrometheusLabelNames(ctx context.Context, ds datasource.DataSource, matchers []string, tr processor.TimeRange) (processor.ConnectionStatus, *processor.PrometheusLabelNamesResult, error) {
|
||||
resp, err := c.sendRequest(ctx,
|
||||
"/api/data-sources/prometheus/labels",
|
||||
NewPrometheusLabelNamesRequest(
|
||||
datasource.NewRunner(ds),
|
||||
matchers,
|
||||
&tr,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if len(resp.Result) == 0 {
|
||||
return resp.Status, nil, nil
|
||||
}
|
||||
|
||||
var result processor.PrometheusLabelNamesResult
|
||||
if err := json.Unmarshal(resp.Result, &result); err != nil {
|
||||
return "", nil, fmt.Errorf("error decoding prometheus label names result: %w", err)
|
||||
}
|
||||
|
||||
return resp.Status, &result, nil
|
||||
}
|
||||
|
||||
// PrometheusLabelValues retrieves Prometheus label values via the connector.
|
||||
func (c *Client) PrometheusLabelValues(ctx context.Context, ds datasource.DataSource, label string, matchers []string, tr processor.TimeRange) (processor.ConnectionStatus, *processor.PrometheusLabelValuesResult, error) {
|
||||
resp, err := c.sendRequest(ctx,
|
||||
"/api/data-sources/prometheus/labels/values",
|
||||
NewPrometheusLabelValuesRequest(
|
||||
datasource.NewRunner(ds),
|
||||
label,
|
||||
matchers,
|
||||
&tr,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if len(resp.Result) == 0 {
|
||||
return resp.Status, nil, nil
|
||||
}
|
||||
|
||||
var result processor.PrometheusLabelValuesResult
|
||||
|
||||
if err := json.Unmarshal(resp.Result, &result); err != nil {
|
||||
return "", nil, fmt.Errorf("error decoding prometheus label values result: %w", err)
|
||||
}
|
||||
|
||||
return resp.Status, &result, nil
|
||||
}
|
||||
|
||||
// PrometheusSeries retrieves Prometheus series via the connector.
|
||||
func (c *Client) PrometheusSeries(ctx context.Context, ds datasource.DataSource, matchers []string, tr processor.TimeRange) (processor.ConnectionStatus, *processor.PrometheusSeriesResult, error) {
|
||||
resp, err := c.sendRequest(
|
||||
ctx,
|
||||
"/api/data-sources/prometheus/series",
|
||||
NewPrometheusSeriesRequest(
|
||||
datasource.NewRunner(ds),
|
||||
matchers,
|
||||
&tr,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if len(resp.Result) == 0 {
|
||||
return resp.Status, nil, nil
|
||||
}
|
||||
|
||||
var result processor.PrometheusSeriesResult
|
||||
|
||||
if err := json.Unmarshal(resp.Result, &result); err != nil {
|
||||
return "", nil, fmt.Errorf("error decoding prometheus series result: %w", err)
|
||||
}
|
||||
|
||||
return resp.Status, &result, nil
|
||||
}
|
||||
62
server/core/internal/datasource/connector/sql.go
Normal file
62
server/core/internal/datasource/connector/sql.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/oxynote/heimdall/internal/datasource"
|
||||
"github.com/oxynote/heimdall/internal/datasource/processor"
|
||||
)
|
||||
|
||||
// SQLQueryLabels executes a query with LIMIT 1 and returns string column names with example values.
|
||||
func (c *Client) SQLQueryLabels(ctx context.Context, ds datasource.DataSource, query string, tr processor.TimeRange) (processor.ConnectionStatus, map[string]string, error) {
|
||||
resp, err := c.sendRequest(ctx,
|
||||
"/api/data-sources/sql/query-labels",
|
||||
NewSQLQueryLabelsRequest(
|
||||
datasource.NewRunner(ds),
|
||||
query,
|
||||
&tr,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if len(resp.Result) == 0 {
|
||||
return resp.Status, nil, nil
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
|
||||
if err := json.Unmarshal(resp.Result, &result); err != nil {
|
||||
return "", nil, fmt.Errorf("error decoding sql query labels result: %w", err)
|
||||
}
|
||||
|
||||
return resp.Status, result, nil
|
||||
}
|
||||
|
||||
// SQLMetadata fetches the metadata (tables and columns) from a SQL data source via the connector.
|
||||
func (c *Client) SQLMetadata(ctx context.Context, ds datasource.DataSource) (processor.ConnectionStatus, *processor.SQLMetadataResult, error) {
|
||||
resp, err := c.sendRequest(ctx,
|
||||
"/api/data-sources/sql/metadata",
|
||||
NewSQLMetadataRequest(
|
||||
datasource.NewRunner(ds),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if len(resp.Result) == 0 {
|
||||
return resp.Status, nil, nil
|
||||
}
|
||||
|
||||
var result processor.SQLMetadataResult
|
||||
|
||||
if err := json.Unmarshal(resp.Result, &result); err != nil {
|
||||
return "", nil, fmt.Errorf("error decoding sql metadata result: %w", err)
|
||||
}
|
||||
|
||||
return resp.Status, &result, nil
|
||||
}
|
||||
227
server/core/internal/datasource/connector/struct.go
Normal file
227
server/core/internal/datasource/connector/struct.go
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
package connector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/oxynote/heimdall/internal/datasource"
|
||||
"github.com/oxynote/heimdall/internal/datasource/processor"
|
||||
)
|
||||
|
||||
// TestConnectionRequest represents a request to test the connection to a data source.
|
||||
type TestConnectionRequest struct {
|
||||
// Runner contains the necessary information to test the connection to the data source.
|
||||
Runner datasource.Runner `json:"runner"`
|
||||
}
|
||||
|
||||
// NewTestConnectionRequest creates a new TestConnectionRequest from the given Runner.
|
||||
func NewTestConnectionRequest(runner datasource.Runner) *TestConnectionRequest {
|
||||
return &TestConnectionRequest{
|
||||
Runner: runner,
|
||||
}
|
||||
}
|
||||
|
||||
// PrometheusQueryRequest represents a request to execute a Prometheus query against a data source.
|
||||
type PrometheusQueryRequest struct {
|
||||
// Runner contains the necessary information to execute the query against the data source.
|
||||
Runner datasource.Runner `json:"runner"`
|
||||
|
||||
// Query is the Prometheus query to be executed.
|
||||
Query string `json:"query"`
|
||||
|
||||
// TimeRange specifies the time range for the query, if applicable.
|
||||
TimeRange *processor.TimeRange `json:"timeRange,omitempty"`
|
||||
}
|
||||
|
||||
// NewPrometheusQueryRequest creates a new PrometheusQueryRequest from the given Runner, query, and time range.
|
||||
func NewPrometheusQueryRequest(runner datasource.Runner, query string, timeRange *processor.TimeRange) *PrometheusQueryRequest {
|
||||
return &PrometheusQueryRequest{
|
||||
Runner: runner,
|
||||
Query: query,
|
||||
TimeRange: timeRange,
|
||||
}
|
||||
}
|
||||
|
||||
// PrometheusMetadataRequest represents a request to fetch metadata from a Prometheus data source.
|
||||
type PrometheusMetadataRequest struct {
|
||||
// Runner contains the necessary information to fetch metadata from the data source.
|
||||
Runner datasource.Runner `json:"runner"`
|
||||
}
|
||||
|
||||
// NewPrometheusMetadataRequest creates a new PrometheusMetadataRequest from the given Runner.
|
||||
func NewPrometheusMetadataRequest(runner datasource.Runner) *PrometheusMetadataRequest {
|
||||
return &PrometheusMetadataRequest{
|
||||
Runner: runner,
|
||||
}
|
||||
}
|
||||
|
||||
// PrometheusLabelNamesRequest represents a request to fetch label names from a Prometheus data source.
|
||||
type PrometheusLabelNamesRequest struct {
|
||||
// Runner contains the necessary information to fetch label names from the data source.
|
||||
Runner datasource.Runner `json:"runner"`
|
||||
|
||||
// Matchers is an optional list of label matchers to filter the label names.
|
||||
Matchers []string `json:"matchers,omitempty"`
|
||||
|
||||
// TimeRange specifies the time range for fetching label names, if applicable.
|
||||
TimeRange *processor.TimeRange `json:"timeRange,omitempty"`
|
||||
}
|
||||
|
||||
// NewPrometheusLabelNamesRequest creates a new PrometheusLabelNamesRequest from the given Runner, matchers, and time range.
|
||||
func NewPrometheusLabelNamesRequest(runner datasource.Runner, matchers []string, timeRange *processor.TimeRange) *PrometheusLabelNamesRequest {
|
||||
return &PrometheusLabelNamesRequest{
|
||||
Runner: runner,
|
||||
Matchers: matchers,
|
||||
TimeRange: timeRange,
|
||||
}
|
||||
}
|
||||
|
||||
// PrometheusLabelValuesRequest represents a request to fetch label values for a specific label from a Prometheus data source.
|
||||
type PrometheusLabelValuesRequest struct {
|
||||
// Runner contains the necessary information to fetch label values from the data source.
|
||||
Runner datasource.Runner `json:"runner"`
|
||||
|
||||
// Label is the name of the label for which to fetch values.
|
||||
Label string `json:"label"`
|
||||
|
||||
// Matchers is an optional list of label matchers to filter the label values.
|
||||
Matchers []string `json:"matchers,omitempty"`
|
||||
|
||||
// TimeRange specifies the time range for fetching label values, if applicable.
|
||||
TimeRange *processor.TimeRange `json:"timeRange,omitempty"`
|
||||
}
|
||||
|
||||
// NewPrometheusLabelValuesRequest creates a new PrometheusLabelValuesRequest from the given Runner, label, matchers, and time range.
|
||||
func NewPrometheusLabelValuesRequest(runner datasource.Runner, label string, matchers []string, timeRange *processor.TimeRange) *PrometheusLabelValuesRequest {
|
||||
return &PrometheusLabelValuesRequest{
|
||||
Runner: runner,
|
||||
Label: label,
|
||||
Matchers: matchers,
|
||||
TimeRange: timeRange,
|
||||
}
|
||||
}
|
||||
|
||||
// PrometheusSeriesRequest represents a request to fetch series matching the given selectors from a Prometheus data source.
|
||||
type PrometheusSeriesRequest struct {
|
||||
// Runner contains the necessary information to fetch series from the data source.
|
||||
Runner datasource.Runner `json:"runner"`
|
||||
|
||||
// Matchers is a list of label matchers to filter the series.
|
||||
Matchers []string `json:"matchers"`
|
||||
|
||||
// TimeRange specifies the time range for fetching series, if applicable.
|
||||
TimeRange *processor.TimeRange `json:"timeRange,omitempty"`
|
||||
}
|
||||
|
||||
// NewPrometheusSeriesRequest creates a new PrometheusSeriesRequest from the given Runner, matchers, and time range.
|
||||
func NewPrometheusSeriesRequest(runner datasource.Runner, matchers []string, timeRange *processor.TimeRange) *PrometheusSeriesRequest {
|
||||
return &PrometheusSeriesRequest{
|
||||
Runner: runner,
|
||||
Matchers: matchers,
|
||||
TimeRange: timeRange,
|
||||
}
|
||||
}
|
||||
|
||||
// PostgreSQLQueryRequest represents a request to execute a SQL query against a PostgreSQL data source.
|
||||
type PostgreSQLQueryRequest struct {
|
||||
// Runner contains the necessary information to execute the query against the data source.
|
||||
Runner datasource.Runner `json:"runner"`
|
||||
|
||||
// Query is the SQL query to be executed.
|
||||
Query string `json:"query"`
|
||||
|
||||
// TimeRange specifies the time range for the query, if applicable.
|
||||
TimeRange *processor.TimeRange `json:"timeRange,omitempty"`
|
||||
}
|
||||
|
||||
// NewPostgreSQLQueryRequest creates a new PostgreSQLQueryRequest from the given Runner, query, and time range.
|
||||
func NewPostgreSQLQueryRequest(runner datasource.Runner, query string, timeRange *processor.TimeRange) *PostgreSQLQueryRequest {
|
||||
return &PostgreSQLQueryRequest{
|
||||
Runner: runner,
|
||||
Query: query,
|
||||
TimeRange: timeRange,
|
||||
}
|
||||
}
|
||||
|
||||
// MySQLQueryRequest represents a request to execute a SQL query against a MySQL data source.
|
||||
type MySQLQueryRequest struct {
|
||||
// Runner contains the necessary information to execute the query against the data source.
|
||||
Runner datasource.Runner `json:"runner"`
|
||||
|
||||
// Query is the SQL query to be executed.
|
||||
Query string `json:"query"`
|
||||
|
||||
// TimeRange specifies the time range for the query, if applicable.
|
||||
TimeRange *processor.TimeRange `json:"timeRange,omitempty"`
|
||||
}
|
||||
|
||||
// NewMySQLQueryRequest creates a new MySQLQueryRequest from the given Runner, query, and time range.
|
||||
func NewMySQLQueryRequest(runner datasource.Runner, query string, timeRange *processor.TimeRange) *MySQLQueryRequest {
|
||||
return &MySQLQueryRequest{
|
||||
Runner: runner,
|
||||
Query: query,
|
||||
TimeRange: timeRange,
|
||||
}
|
||||
}
|
||||
|
||||
// SQLQueryLabelsRequest represents a request to fetch label columns and example values from a SQL query.
|
||||
type SQLQueryLabelsRequest struct {
|
||||
// Runner contains the necessary information to connect to the data source.
|
||||
Runner datasource.Runner `json:"runner"`
|
||||
|
||||
// Query is the SQL query to be executed.
|
||||
Query string `json:"query"`
|
||||
|
||||
// TimeRange specifies the time range for the query, if applicable.
|
||||
TimeRange *processor.TimeRange `json:"timeRange,omitempty"`
|
||||
}
|
||||
|
||||
// NewSQLQueryLabelsRequest creates a new SQLQueryLabelsRequest from the given Runner, query, and time range.
|
||||
func NewSQLQueryLabelsRequest(runner datasource.Runner, query string, timeRange *processor.TimeRange) *SQLQueryLabelsRequest {
|
||||
return &SQLQueryLabelsRequest{
|
||||
Runner: runner,
|
||||
Query: query,
|
||||
TimeRange: timeRange,
|
||||
}
|
||||
}
|
||||
|
||||
// SQLMetadataRequest represents a request to fetch metadata from a SQL data source.
|
||||
type SQLMetadataRequest struct {
|
||||
// Runner contains the necessary information to connect to the data source.
|
||||
Runner datasource.Runner `json:"runner"`
|
||||
}
|
||||
|
||||
// NewSQLMetadataRequest creates a new SQLMetadataRequest from the given Runner.
|
||||
func NewSQLMetadataRequest(runner datasource.Runner) *SQLMetadataRequest {
|
||||
return &SQLMetadataRequest{
|
||||
Runner: runner,
|
||||
}
|
||||
}
|
||||
|
||||
// Response represents the response from the connector service.
|
||||
type Response struct {
|
||||
// Status indicates the connection status after processing the request.
|
||||
Status processor.ConnectionStatus `json:"status"`
|
||||
|
||||
// Result contains any additional data returned by the connector, such as query
|
||||
// results or error details.
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// NewResponse creates a new Response with the given connection status and result data.
|
||||
func NewResponse(status processor.ConnectionStatus, result any) (*Response, error) {
|
||||
var rawResult json.RawMessage
|
||||
|
||||
if result != nil {
|
||||
encodedResult, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawResult = encodedResult
|
||||
}
|
||||
|
||||
return &Response{
|
||||
Status: status,
|
||||
Result: rawResult,
|
||||
}, nil
|
||||
}
|
||||
180
server/core/internal/datasource/datasource.go
Normal file
180
server/core/internal/datasource/datasource.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package datasource
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/oxynote/heimdall/internal/datasource/processor"
|
||||
"github.com/oxynote/purse/util/errutil"
|
||||
"github.com/oxynote/purse/util/timeutil"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// ErrUnavailableDataSource is returned when the data source is unavailable.
|
||||
var ErrUnavailableDataSource = errutil.New(http.StatusBadRequest, "data_source.unavailable", "The data source is currently unavailable.")
|
||||
|
||||
// Type represents the type of the data source.
|
||||
type Type string
|
||||
|
||||
const (
|
||||
// TypePrometheus represents a Prometheus data source.
|
||||
TypePrometheus Type = "prometheus"
|
||||
|
||||
// TypePostgreSQL represents a PostgreSQL data source.
|
||||
TypePostgreSQL Type = "postgresql"
|
||||
|
||||
// TypeMariaDB represents a MariaDB data source.
|
||||
TypeMariaDB Type = "mariadb"
|
||||
|
||||
// TypeMySQL represents a MySQL data source.
|
||||
TypeMySQL Type = "mysql"
|
||||
)
|
||||
|
||||
// DataSource represents a generic data source.
|
||||
type DataSource struct {
|
||||
// ID is the unique identifier for the data source.
|
||||
ID xid.ID `json:"id" db:"id"`
|
||||
|
||||
// OrganizationID is the ID of the organization that owns the data source.
|
||||
OrganizationID string `json:"-" db:"fk_organization_id"`
|
||||
|
||||
// Name is the name of the data source.
|
||||
Name string `json:"name" db:"name"`
|
||||
|
||||
// Type indicates the type of the data source (e.g., Prometheus).
|
||||
Type Type `json:"type" db:"type"`
|
||||
|
||||
// URL is the endpoint URL of the data source.
|
||||
URL string `json:"url" db:"url"`
|
||||
|
||||
// Credentials holds the authentication credentials for the data source.
|
||||
Credentials processor.Credentials `json:"-" db:"credentials"`
|
||||
|
||||
// Status indicates the current connection status of the data source.
|
||||
Status processor.ConnectionStatus `json:"status" db:"status"`
|
||||
|
||||
// CreatedAt is the timestamp when the data source was created.
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
|
||||
// UpdatedAt is the timestamp when the data source was last updated.
|
||||
UpdatedAt null.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
// NewDataSource creates a new instance of DataSource.
|
||||
func NewDataSource(ci CreateInput, organizationID string) *DataSource {
|
||||
return &DataSource{
|
||||
ID: xid.New(),
|
||||
OrganizationID: organizationID,
|
||||
Type: ci.Type,
|
||||
Name: ci.Name,
|
||||
URL: ci.URL,
|
||||
Credentials: ci.Credentials,
|
||||
Status: processor.ConnectionStatusSuccess,
|
||||
CreatedAt: timeutil.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Info returns a subset of data source fields for external use.
|
||||
func (ds *DataSource) Info() DataSourceInfo {
|
||||
return DataSourceInfo{
|
||||
ID: ds.ID,
|
||||
Name: ds.Name,
|
||||
Type: ds.Type,
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyUpdate applies the given update input to the data source.
|
||||
func (ds *DataSource) ApplyUpdate(ui UpdateInput) error {
|
||||
if ui.Name.Valid {
|
||||
ds.Name = ui.Name.String
|
||||
}
|
||||
|
||||
if ui.URL.Valid {
|
||||
ds.URL = ui.URL.String
|
||||
}
|
||||
|
||||
err := ds.updateCredentials(ui.Credentials)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ds.UpdatedAt = null.TimeFrom(timeutil.Now())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateCredentials updates the data source credentials.
|
||||
func (ds *DataSource) updateCredentials(inp null.Value[processor.CredentialsUpdateInput]) error {
|
||||
if !inp.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch ds.Type {
|
||||
case TypePrometheus:
|
||||
creds, err := processor.UpdatePrometheusCredentials(ds.Credentials, inp.V)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ds.Credentials = creds
|
||||
case TypePostgreSQL:
|
||||
creds, err := processor.UpdatePostgreSQLCredentials(ds.Credentials, inp.V)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ds.Credentials = creds
|
||||
case TypeMariaDB, TypeMySQL:
|
||||
creds, err := processor.UpdateMySQLCredentials(ds.Credentials, inp.V)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ds.Credentials = creds
|
||||
default:
|
||||
return errors.New("invalid data source type")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DataSourceInfo is the subset of data source fields returned to the AI.
|
||||
type DataSourceInfo struct {
|
||||
// ID is the unique identifier for the data source.
|
||||
ID xid.ID `json:"id"`
|
||||
|
||||
// Name is the name of the data source.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Type indicates the type of the data source (e.g., Prometheus).
|
||||
Type Type `json:"type"`
|
||||
}
|
||||
|
||||
// CreateInput represents the data required to create a new data source.
|
||||
type CreateInput struct {
|
||||
// Type is the type of the hook.
|
||||
Type Type `json:"type"`
|
||||
|
||||
// Name is the name of the data source.
|
||||
Name string `json:"name"`
|
||||
|
||||
// URL is the endpoint URL of the data source.
|
||||
URL string `json:"url"`
|
||||
|
||||
// Credentials holds the authentication credentials for the data source.
|
||||
Credentials processor.Credentials `json:"credentials"`
|
||||
}
|
||||
|
||||
// UpdateInput represents the data required to update an existing freshness hook.
|
||||
type UpdateInput struct {
|
||||
// Name is the name of the data source.
|
||||
Name null.String `json:"name"`
|
||||
|
||||
// URL is the endpoint URL of the data source.
|
||||
URL null.String `json:"url"`
|
||||
|
||||
// Credentials holds the authentication credentials for the data source.
|
||||
Credentials null.Value[processor.CredentialsUpdateInput] `json:"credentials"`
|
||||
}
|
||||
31
server/core/internal/datasource/input.go
Normal file
31
server/core/internal/datasource/input.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package datasource
|
||||
|
||||
import "github.com/oxynote/heimdall/internal/datasource/processor"
|
||||
|
||||
// stateInput is an implementation of the Input interface
|
||||
// that includes credentials.
|
||||
type stateInput struct {
|
||||
url string
|
||||
credentials processor.Credentials
|
||||
}
|
||||
|
||||
// newStateInput creates a new state input with the given credentials.
|
||||
func newStateInput(
|
||||
url string,
|
||||
credentials processor.Credentials,
|
||||
) *stateInput {
|
||||
return &stateInput{
|
||||
url: url,
|
||||
credentials: credentials,
|
||||
}
|
||||
}
|
||||
|
||||
// URL returns the URL of the data source.
|
||||
func (si *stateInput) URL() string {
|
||||
return si.url
|
||||
}
|
||||
|
||||
// Credentials returns the credentials of the data source.
|
||||
func (si *stateInput) Credentials() processor.Credentials {
|
||||
return si.credentials
|
||||
}
|
||||
648
server/core/internal/datasource/processor/mysql.go
Normal file
648
server/core/internal/datasource/processor/mysql.go
Normal file
|
|
@ -0,0 +1,648 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/guregu/null/v5"
|
||||
)
|
||||
|
||||
// _mysqlReadOnlyPrivileges is the set of MySQL/MariaDB privileges considered read-only.
|
||||
var _mysqlReadOnlyPrivileges = map[string]struct{}{
|
||||
"SELECT": {},
|
||||
"SHOW VIEW": {},
|
||||
"SHOW DATABASES": {},
|
||||
"PROCESS": {},
|
||||
"REPLICATION CLIENT": {},
|
||||
"REPLICATION SLAVE": {},
|
||||
"USAGE": {},
|
||||
}
|
||||
|
||||
// MySQL represents a MySQL data source processor.
|
||||
type MySQL struct {
|
||||
inp Input
|
||||
}
|
||||
|
||||
// NewMySQL creates a new MySQL data source processor.
|
||||
func NewMySQL(inp Input) *MySQL {
|
||||
return &MySQL{
|
||||
inp: inp,
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnection tests the connection to the MySQL data source.
|
||||
func (m *MySQL) TestConnection(ctx context.Context) (ConnectionStatus, error) {
|
||||
db, err := m.connect()
|
||||
if err != nil {
|
||||
return ConnectionStatusUnreachable, nil
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return ConnectionStatusUnreachable, nil
|
||||
}
|
||||
|
||||
readOnly, err := mysqlCheckReadOnly(ctx, db)
|
||||
if err != nil {
|
||||
return ConnectionStatusUnreachable, nil
|
||||
}
|
||||
|
||||
if !readOnly {
|
||||
return ConnectionStatusNotReadOnly, nil
|
||||
}
|
||||
|
||||
return ConnectionStatusSuccess, nil
|
||||
}
|
||||
|
||||
// Metadata retrieves all tables and their columns from the MySQL data source.
|
||||
func (m *MySQL) Metadata(ctx context.Context) (*SQLMetadataResult, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, _queryTimeout)
|
||||
defer cancel()
|
||||
|
||||
db, err := m.connect()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error connecting to mysql: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.QueryContext(ctx,
|
||||
"SELECT table_schema, table_name, column_name FROM information_schema.columns WHERE table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') ORDER BY table_schema, table_name, ordinal_position",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching metadata: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tables := make(map[string]SQLTable)
|
||||
|
||||
for rows.Next() {
|
||||
var schema, tableName, columnName string
|
||||
|
||||
if err := rows.Scan(&schema, &tableName, &columnName); err != nil {
|
||||
return nil, fmt.Errorf("error scanning metadata: %w", err)
|
||||
}
|
||||
|
||||
key := schema + "." + tableName
|
||||
|
||||
table := tables[key]
|
||||
table.Columns = append(table.Columns, SQLColumn{Name: columnName})
|
||||
tables[key] = table
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating metadata: %w", err)
|
||||
}
|
||||
|
||||
var defaultSchema string
|
||||
|
||||
err = db.QueryRowContext(ctx, "SELECT DATABASE()").Scan(&defaultSchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching default schema: %w", err)
|
||||
}
|
||||
|
||||
return &SQLMetadataResult{
|
||||
Tables: tables,
|
||||
DefaultSchema: defaultSchema,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryLabels executes a SQL query with LIMIT 1 against the MySQL data source
|
||||
// and returns the string (label) columns with their example values.
|
||||
func (m *MySQL) QueryLabels(ctx context.Context, q string, tr TimeRange) (map[string]string, error) {
|
||||
q = tr.ProcessMySQLQuery(q)
|
||||
q = sqlSetLimit(q, 1)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, _queryTimeout)
|
||||
defer cancel()
|
||||
|
||||
db, err := m.connect()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error connecting to mysql: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number >= 1064 && mysqlErr.Number <= 1149 {
|
||||
return nil, NewInvalidQueryError(mysqlErr.Message)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("error executing query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading columns: %w", err)
|
||||
}
|
||||
|
||||
labels := make(map[string]string)
|
||||
|
||||
if !rows.Next() {
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
dest := make([]any, len(columns))
|
||||
ptrs := make([]any, len(columns))
|
||||
|
||||
for i := range dest {
|
||||
ptrs[i] = &dest[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return nil, fmt.Errorf("error scanning row: %w", err)
|
||||
}
|
||||
|
||||
for i, v := range dest {
|
||||
if s, ok := v.([]byte); ok {
|
||||
labels[columns[i]] = string(s)
|
||||
} else if s, ok := v.(string); ok {
|
||||
labels[columns[i]] = s
|
||||
}
|
||||
}
|
||||
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
// Query executes a SQL query against the MySQL data source.
|
||||
func (m *MySQL) Query(ctx context.Context, q string, tr TimeRange) (*MySQLQueryResult, error) {
|
||||
q = tr.ProcessMySQLQuery(q)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, _queryTimeout)
|
||||
defer cancel()
|
||||
|
||||
db, err := m.connect()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error connecting to mysql: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number >= 1064 && mysqlErr.Number <= 1149 {
|
||||
return nil, NewInvalidQueryError(mysqlErr.Message)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("error executing query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading columns: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
resultRows [][]any
|
||||
payloadSize int
|
||||
)
|
||||
|
||||
for rows.Next() {
|
||||
dest := make([]any, len(columns))
|
||||
ptrs := make([]any, len(columns))
|
||||
|
||||
for i := range dest {
|
||||
ptrs[i] = &dest[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return nil, fmt.Errorf("error scanning row: %w", err)
|
||||
}
|
||||
|
||||
for i, v := range dest {
|
||||
payloadSize += mysqlEstimateValueSize(v)
|
||||
|
||||
// The MySQL driver returns string values as []byte.
|
||||
// Convert to string so they JSON-marshal as strings, not base64.
|
||||
if b, ok := v.([]byte); ok {
|
||||
dest[i] = string(b)
|
||||
}
|
||||
}
|
||||
|
||||
if payloadSize > _queryPayloadLimit {
|
||||
return nil, NewInvalidQueryError("query result exceeds the maximum payload size")
|
||||
}
|
||||
|
||||
resultRows = append(resultRows, dest)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating rows: %w", err)
|
||||
}
|
||||
|
||||
return &MySQLQueryResult{
|
||||
Columns: columns,
|
||||
Rows: resultRows,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// connect creates a database/sql connection using the data source URL and credentials.
|
||||
func (m *MySQL) connect() (*sql.DB, error) {
|
||||
dsn, err := m.buildDSN()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error connecting to mysql: %w", err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
db.SetConnMaxLifetime(30 * time.Second)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// buildDSN builds a MySQL DSN from the URL and credentials.
|
||||
// Input URL format: mysql://user:pass@host:3306/db
|
||||
// Output DSN format: user:pass@tcp(host:port)/db?parseTime=true&timeout=10s
|
||||
func (m *MySQL) buildDSN() (string, error) {
|
||||
u, err := url.Parse(m.inp.URL())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error parsing mysql url: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
username string
|
||||
password string
|
||||
)
|
||||
|
||||
if u.User != nil {
|
||||
username = u.User.Username()
|
||||
password, _ = u.User.Password()
|
||||
}
|
||||
|
||||
if m.inp.Credentials() != nil {
|
||||
var creds MySQLCredentials
|
||||
|
||||
if err := json.Unmarshal(m.inp.Credentials(), &creds); err != nil {
|
||||
return "", fmt.Errorf("error unmarshaling credentials: %w", err)
|
||||
}
|
||||
|
||||
if creds.Username != "" || creds.Password != "" {
|
||||
username = creds.Username
|
||||
password = creds.Password
|
||||
}
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
port := u.Port()
|
||||
|
||||
if port == "" {
|
||||
port = "3306"
|
||||
}
|
||||
|
||||
dbName := strings.TrimPrefix(u.Path, "/")
|
||||
|
||||
cfg := mysql.NewConfig()
|
||||
cfg.User = username
|
||||
cfg.Passwd = password
|
||||
cfg.Net = "tcp"
|
||||
cfg.Addr = host + ":" + port
|
||||
cfg.DBName = dbName
|
||||
cfg.ParseTime = true
|
||||
cfg.Timeout = 10 * time.Second
|
||||
|
||||
return cfg.FormatDSN(), nil
|
||||
}
|
||||
|
||||
// UpdateMySQLCredentials updates the credentials for a MySQL data source.
|
||||
func UpdateMySQLCredentials(rawCreds Credentials, inp CredentialsUpdateInput) (Credentials, error) {
|
||||
var creds MySQLCredentials
|
||||
|
||||
if rawCreds != nil {
|
||||
if err := json.Unmarshal(rawCreds, &creds); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling credentials: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var update MySQLCredentialsUpdate
|
||||
|
||||
if err := json.Unmarshal(inp, &update); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling credentials update input: %w", err)
|
||||
}
|
||||
|
||||
if update.Username.Valid {
|
||||
creds.Username = update.Username.String
|
||||
}
|
||||
|
||||
if update.Password.Valid {
|
||||
creds.Password = update.Password.String
|
||||
}
|
||||
|
||||
if creds.Username == "" && creds.Password == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshaling updated credentials: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// MySQLCredentials represents the credentials for a MySQL data source.
|
||||
type MySQLCredentials struct {
|
||||
// Username is the username for the MySQL data source.
|
||||
Username string `json:"username"`
|
||||
|
||||
// Password is the password for the MySQL data source.
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// MySQLCredentialsUpdate represents the input for updating MySQL credentials.
|
||||
type MySQLCredentialsUpdate struct {
|
||||
// Username is the username for the MySQL data source.
|
||||
Username null.String `json:"username"`
|
||||
|
||||
// Password is the password for the MySQL data source.
|
||||
Password null.String `json:"password"`
|
||||
}
|
||||
|
||||
// MySQLQueryResult represents the result of a MySQL query.
|
||||
type MySQLQueryResult struct {
|
||||
// Columns contains the column names of the result set.
|
||||
Columns []string `json:"columns"`
|
||||
|
||||
// Rows contains the result rows, each as a slice of values.
|
||||
Rows [][]any `json:"rows"`
|
||||
}
|
||||
|
||||
// Transform transforms a MySQLQueryResult into a unified QueryResult
|
||||
// based on the requested chart type.
|
||||
func (mqr *MySQLQueryResult) Transform(ct ChartType) *QueryResult {
|
||||
if ct == "" {
|
||||
return &QueryResult{Status: QueryStatusTypeNotSelected}
|
||||
}
|
||||
|
||||
if len(mqr.Columns) == 0 || len(mqr.Rows) == 0 {
|
||||
return &QueryResult{Status: QueryStatusNoData}
|
||||
}
|
||||
|
||||
timeIdx, valueIdxs, labelIdxs := mqr.identifyColumns()
|
||||
if timeIdx < 0 || len(valueIdxs) == 0 {
|
||||
return &QueryResult{Status: QueryStatusChartAndDataMismatch}
|
||||
}
|
||||
|
||||
multipleValues := len(valueIdxs) > 1
|
||||
|
||||
seriesMap := make(map[string]*QueryResultSeries)
|
||||
var seriesOrder []string
|
||||
|
||||
for _, row := range mqr.Rows {
|
||||
if len(row) <= timeIdx {
|
||||
continue
|
||||
}
|
||||
|
||||
ts, ok := mysqlParseTimestamp(row[timeIdx])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
labels := make(map[string]string, len(labelIdxs))
|
||||
var labelKeyParts []string
|
||||
|
||||
for _, li := range labelIdxs {
|
||||
var lbl string
|
||||
if li < len(row) && row[li] != nil {
|
||||
lbl = fmt.Sprintf("%v", row[li])
|
||||
}
|
||||
|
||||
labels[mqr.Columns[li]] = lbl
|
||||
labelKeyParts = append(labelKeyParts, mqr.Columns[li]+"="+lbl)
|
||||
}
|
||||
|
||||
labelKey := strings.Join(labelKeyParts, "\x00")
|
||||
|
||||
for _, vi := range valueIdxs {
|
||||
if vi >= len(row) {
|
||||
continue
|
||||
}
|
||||
|
||||
val, ok := mysqlParseNumericValue(row[vi])
|
||||
if !ok || !isValidValue(val) {
|
||||
continue
|
||||
}
|
||||
|
||||
key := mqr.Columns[vi] + "\x00" + labelKey
|
||||
|
||||
if _, exists := seriesMap[key]; !exists {
|
||||
seriesLabels := make(map[string]string, len(labels)+1)
|
||||
for k, v := range labels {
|
||||
seriesLabels[k] = v
|
||||
}
|
||||
|
||||
if multipleValues {
|
||||
seriesLabels["__name__"] = mqr.Columns[vi]
|
||||
}
|
||||
|
||||
seriesMap[key] = &QueryResultSeries{
|
||||
Labels: seriesLabels,
|
||||
}
|
||||
seriesOrder = append(seriesOrder, key)
|
||||
}
|
||||
|
||||
seriesMap[key].Metrics = append(seriesMap[key].Metrics, [2]any{ts, val})
|
||||
}
|
||||
}
|
||||
|
||||
if len(seriesMap) == 0 {
|
||||
return &QueryResult{Status: QueryStatusNoData}
|
||||
}
|
||||
|
||||
series := make([]QueryResultSeries, 0, len(seriesOrder))
|
||||
|
||||
for _, key := range seriesOrder {
|
||||
s := seriesMap[key]
|
||||
|
||||
if ct == ChartTypeGauge && len(s.Metrics) > 0 {
|
||||
s.Metrics = s.Metrics[len(s.Metrics)-1:]
|
||||
}
|
||||
|
||||
series = append(series, *s)
|
||||
}
|
||||
|
||||
return &QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: series,
|
||||
}
|
||||
}
|
||||
|
||||
// identifyColumns detects which columns are time, value (numeric), and labels (non-numeric).
|
||||
func (mqr *MySQLQueryResult) identifyColumns() (timeIdx int, valueIdxs []int, labelIdxs []int) {
|
||||
timeIdx = -1
|
||||
|
||||
for i, col := range mqr.Columns {
|
||||
if strings.ToLower(col) == "time" {
|
||||
timeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(mqr.Rows) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
firstRow := mqr.Rows[0]
|
||||
|
||||
for i := range mqr.Columns {
|
||||
if i == timeIdx {
|
||||
continue
|
||||
}
|
||||
|
||||
if i < len(firstRow) {
|
||||
if _, ok := mysqlParseNumericValue(firstRow[i]); ok {
|
||||
valueIdxs = append(valueIdxs, i)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
labelIdxs = append(labelIdxs, i)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// mysqlCheckReadOnly checks whether the connected user has only read-only privileges
|
||||
// by parsing the output of SHOW GRANTS FOR CURRENT_USER().
|
||||
func mysqlCheckReadOnly(ctx context.Context, db *sql.DB) (bool, error) {
|
||||
rows, err := db.QueryContext(ctx, "SHOW GRANTS FOR CURRENT_USER()")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var grant string
|
||||
if err := rows.Scan(&grant); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
grant = strings.ToUpper(grant)
|
||||
|
||||
// Extract the privileges portion between "GRANT " and " ON ".
|
||||
grantIdx := strings.Index(grant, "GRANT ")
|
||||
onIdx := strings.Index(grant, " ON ")
|
||||
|
||||
if grantIdx < 0 || onIdx < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
privs := grant[grantIdx+len("GRANT ") : onIdx]
|
||||
|
||||
if strings.Contains(privs, "ALL PRIVILEGES") {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for _, p := range strings.Split(privs, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := _mysqlReadOnlyPrivileges[p]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// mysqlParseTimestamp parses a timestamp value from a MySQL result row.
|
||||
// It handles float64 (unix seconds), int64 (unix seconds), time.Time, and string (RFC3339) values.
|
||||
func mysqlParseTimestamp(v any) (int64, bool) {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return int64(val), true
|
||||
case int64:
|
||||
return val, true
|
||||
case time.Time:
|
||||
return val.Unix(), true
|
||||
case string:
|
||||
t, err := time.Parse(time.RFC3339Nano, val)
|
||||
if err != nil {
|
||||
t, err = time.Parse(time.RFC3339, val)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
return t.Unix(), true
|
||||
case []byte:
|
||||
s := string(val)
|
||||
|
||||
// Try parsing as a number first.
|
||||
if n, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return int64(n), true
|
||||
}
|
||||
|
||||
t, err := time.Parse(time.RFC3339Nano, s)
|
||||
if err != nil {
|
||||
t, err = time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
return t.Unix(), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// mysqlParseNumericValue parses a numeric value from a MySQL result row.
|
||||
// It handles float64, int64, and []byte (for DECIMAL columns).
|
||||
func mysqlParseNumericValue(v any) (float64, bool) {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val, true
|
||||
case int64:
|
||||
return float64(val), true
|
||||
case []byte:
|
||||
f, err := strconv.ParseFloat(string(val), 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return f, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// mysqlEstimateValueSize returns a rough byte-size estimate for a single value
|
||||
// as it would appear in a JSON-encoded response.
|
||||
func mysqlEstimateValueSize(v any) int {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
return 4 // "null"
|
||||
case bool:
|
||||
return 5 // "false"
|
||||
case string:
|
||||
return len(val) + 2
|
||||
case []byte:
|
||||
return len(val)
|
||||
default:
|
||||
return 8 // numeric types
|
||||
}
|
||||
}
|
||||
155
server/core/internal/datasource/processor/mysql_macros.go
Normal file
155
server/core/internal/datasource/processor/mysql_macros.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// _mysqlMacroRe matches MySQL macro invocations: $__macroName(args...).
|
||||
var _mysqlMacroRe = regexp.MustCompile(`\$__(\w+)\(([^)]*)\)`)
|
||||
|
||||
// ProcessMySQLQuery processes the query string to replace MySQL-specific
|
||||
// macros with appropriate SQL expressions.
|
||||
//
|
||||
// Supported macros:
|
||||
//
|
||||
// $__time(dateColumn) → UNIX_TIMESTAMP(dateColumn) AS `time`
|
||||
// $__timeEpoch(dateColumn) → UNIX_TIMESTAMP(dateColumn) AS `time`
|
||||
// $__timeFilter(dateColumn) → dateColumn BETWEEN 'from' AND 'to'
|
||||
// $__timeFrom() → 'from'
|
||||
// $__timeTo() → 'to'
|
||||
// $__timeGroup(dateColumn,'5m'[, fill]) → FLOOR(UNIX_TIMESTAMP(dateColumn)/300)*300
|
||||
// $__timeGroupAlias(dateColumn,'5m'[, fill]) → FLOOR(UNIX_TIMESTAMP(dateColumn)/300)*300 AS `time`
|
||||
// $__unixEpochFilter(dateColumn) → dateColumn >= unixFrom AND dateColumn <= unixTo
|
||||
// $__unixEpochFrom() → unix seconds
|
||||
// $__unixEpochTo() → unix seconds
|
||||
// $__unixEpochNanoFilter(dateColumn) → dateColumn >= nanoFrom AND dateColumn <= nanoTo
|
||||
// $__unixEpochNanoFrom() → unix nanoseconds
|
||||
// $__unixEpochNanoTo() → unix nanoseconds
|
||||
// $__unixEpochGroup(dateColumn,'5m'[, fill]) → floor(dateColumn/300)*300
|
||||
// $__unixEpochGroupAlias(dateColumn,'5m'[, fill]) → floor(dateColumn/300)*300 AS `time`
|
||||
func (tr TimeRange) ProcessMySQLQuery(q string) string {
|
||||
// Resolve generic macros first so they can be used as macro arguments
|
||||
// (e.g., $__timeGroupAlias("time", $__interval)).
|
||||
q = tr.ProcessQuery(q)
|
||||
|
||||
q = _mysqlMacroRe.ReplaceAllStringFunc(q, func(match string) string {
|
||||
submatch := _mysqlMacroRe.FindStringSubmatch(match)
|
||||
if len(submatch) != 3 {
|
||||
return match
|
||||
}
|
||||
|
||||
name := submatch[1]
|
||||
args := parseMacroArgs(submatch[2])
|
||||
|
||||
switch name {
|
||||
case "time", "timeEpoch":
|
||||
return mysqlMacroTime(args, match)
|
||||
case "timeFilter":
|
||||
return mysqlMacroTimeFilter(tr, args, match)
|
||||
case "timeFrom":
|
||||
return mysqlMacroTimeFrom(tr)
|
||||
case "timeTo":
|
||||
return mysqlMacroTimeTo(tr)
|
||||
case "timeGroup":
|
||||
return mysqlMacroTimeGroup(args, match)
|
||||
case "timeGroupAlias":
|
||||
return mysqlMacroTimeGroupAlias(args, match)
|
||||
case "unixEpochFilter":
|
||||
return pgMacroUnixEpochFilter(tr, args, match)
|
||||
case "unixEpochFrom":
|
||||
return pgMacroUnixEpochFrom(tr)
|
||||
case "unixEpochTo":
|
||||
return pgMacroUnixEpochTo(tr)
|
||||
case "unixEpochNanoFilter":
|
||||
return pgMacroUnixEpochNanoFilter(tr, args, match)
|
||||
case "unixEpochNanoFrom":
|
||||
return pgMacroUnixEpochNanoFrom(tr)
|
||||
case "unixEpochNanoTo":
|
||||
return pgMacroUnixEpochNanoTo(tr)
|
||||
case "unixEpochGroup":
|
||||
return pgMacroUnixEpochGroup(args, match)
|
||||
case "unixEpochGroupAlias":
|
||||
return mysqlMacroUnixEpochGroupAlias(args, match)
|
||||
default:
|
||||
return match
|
||||
}
|
||||
})
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
// $__time(dateColumn) / $__timeEpoch(dateColumn)
|
||||
func mysqlMacroTime(args []string, original string) string {
|
||||
if len(args) < 1 {
|
||||
return original
|
||||
}
|
||||
|
||||
return fmt.Sprintf("UNIX_TIMESTAMP(%s) AS `time`", args[0])
|
||||
}
|
||||
|
||||
// $__timeFilter(dateColumn)
|
||||
func mysqlMacroTimeFilter(tr TimeRange, args []string, original string) string {
|
||||
if len(args) < 1 {
|
||||
return original
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s BETWEEN '%s' AND '%s'",
|
||||
args[0],
|
||||
tr.From.UTC().Format(time.RFC3339),
|
||||
tr.To.UTC().Format(time.RFC3339),
|
||||
)
|
||||
}
|
||||
|
||||
// $__timeFrom()
|
||||
func mysqlMacroTimeFrom(tr TimeRange) string {
|
||||
return fmt.Sprintf("'%s'", tr.From.UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// $__timeTo()
|
||||
func mysqlMacroTimeTo(tr TimeRange) string {
|
||||
return fmt.Sprintf("'%s'", tr.To.UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// $__timeGroup(dateColumn,'5m'[, fill])
|
||||
func mysqlMacroTimeGroup(args []string, original string) string {
|
||||
if len(args) < 2 {
|
||||
return original
|
||||
}
|
||||
|
||||
seconds, ok := parseInterval(args[1])
|
||||
if !ok {
|
||||
return original
|
||||
}
|
||||
|
||||
return fmt.Sprintf("FLOOR(UNIX_TIMESTAMP(%s)/%d)*%d", args[0], seconds, seconds)
|
||||
}
|
||||
|
||||
// $__timeGroupAlias(dateColumn,'5m'[, fill])
|
||||
func mysqlMacroTimeGroupAlias(args []string, original string) string {
|
||||
if len(args) < 2 {
|
||||
return original
|
||||
}
|
||||
|
||||
seconds, ok := parseInterval(args[1])
|
||||
if !ok {
|
||||
return original
|
||||
}
|
||||
|
||||
return fmt.Sprintf("FLOOR(UNIX_TIMESTAMP(%s)/%d)*%d AS `time`", args[0], seconds, seconds)
|
||||
}
|
||||
|
||||
// $__unixEpochGroupAlias(dateColumn,'5m'[, fill])
|
||||
func mysqlMacroUnixEpochGroupAlias(args []string, original string) string {
|
||||
if len(args) < 2 {
|
||||
return original
|
||||
}
|
||||
|
||||
seconds, ok := parseInterval(args[1])
|
||||
if !ok {
|
||||
return original
|
||||
}
|
||||
|
||||
return fmt.Sprintf("floor(%s/%d)*%d AS `time`", args[0], seconds, seconds)
|
||||
}
|
||||
164
server/core/internal/datasource/processor/mysql_macros_test.go
Normal file
164
server/core/internal/datasource/processor/mysql_macros_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_TimeRange_ProcessMySQLQuery(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
TimeRange TimeRange
|
||||
Query string
|
||||
Result string
|
||||
}{
|
||||
"$__time": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__time(created_at), value FROM metrics",
|
||||
Result: "SELECT UNIX_TIMESTAMP(created_at) AS `time`, value FROM metrics",
|
||||
},
|
||||
"$__timeEpoch": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__timeEpoch(created_at), value FROM metrics",
|
||||
Result: "SELECT UNIX_TIMESTAMP(created_at) AS `time`, value FROM metrics",
|
||||
},
|
||||
"$__timeFilter": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE $__timeFilter(created_at)",
|
||||
Result: "SELECT * FROM metrics WHERE created_at BETWEEN '2024-01-15T10:00:00Z' AND '2024-01-15T11:00:00Z'",
|
||||
},
|
||||
"$__timeFrom": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE created_at >= $__timeFrom()",
|
||||
Result: "SELECT * FROM metrics WHERE created_at >= '2024-01-15T10:00:00Z'",
|
||||
},
|
||||
"$__timeTo": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE created_at <= $__timeTo()",
|
||||
Result: "SELECT * FROM metrics WHERE created_at <= '2024-01-15T11:00:00Z'",
|
||||
},
|
||||
"$__timeFrom and $__timeTo together": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE created_at >= $__timeFrom() AND created_at <= $__timeTo()",
|
||||
Result: "SELECT * FROM metrics WHERE created_at >= '2024-01-15T10:00:00Z' AND created_at <= '2024-01-15T11:00:00Z'",
|
||||
},
|
||||
"$__timeGroup with 5m": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__timeGroup(created_at,'5m'), avg(value) FROM metrics GROUP BY 1",
|
||||
Result: "SELECT FLOOR(UNIX_TIMESTAMP(created_at)/300)*300, avg(value) FROM metrics GROUP BY 1",
|
||||
},
|
||||
"$__timeGroup with 1h": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__timeGroup(created_at,'1h') FROM metrics",
|
||||
Result: "SELECT FLOOR(UNIX_TIMESTAMP(created_at)/3600)*3600 FROM metrics",
|
||||
},
|
||||
"$__timeGroup with fill mode 0": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__timeGroup(created_at,'5m', 0), avg(value) FROM metrics GROUP BY 1",
|
||||
Result: "SELECT FLOOR(UNIX_TIMESTAMP(created_at)/300)*300, avg(value) FROM metrics GROUP BY 1",
|
||||
},
|
||||
"$__timeGroup with fill mode NULL": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__timeGroup(created_at,'5m', NULL), avg(value) FROM metrics GROUP BY 1",
|
||||
Result: "SELECT FLOOR(UNIX_TIMESTAMP(created_at)/300)*300, avg(value) FROM metrics GROUP BY 1",
|
||||
},
|
||||
"$__timeGroup with fill mode previous": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__timeGroup(created_at,'5m', previous) FROM metrics",
|
||||
Result: "SELECT FLOOR(UNIX_TIMESTAMP(created_at)/300)*300 FROM metrics",
|
||||
},
|
||||
"$__timeGroupAlias with 5m": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__timeGroupAlias(created_at,'5m'), avg(value) FROM metrics GROUP BY 1",
|
||||
Result: "SELECT FLOOR(UNIX_TIMESTAMP(created_at)/300)*300 AS `time`, avg(value) FROM metrics GROUP BY 1",
|
||||
},
|
||||
"$__unixEpochFilter": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE $__unixEpochFilter(epoch_col)",
|
||||
Result: "SELECT * FROM metrics WHERE epoch_col >= 1705312800 AND epoch_col <= 1705316400",
|
||||
},
|
||||
"$__unixEpochFrom": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE epoch >= $__unixEpochFrom()",
|
||||
Result: "SELECT * FROM metrics WHERE epoch >= 1705312800",
|
||||
},
|
||||
"$__unixEpochTo": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE epoch <= $__unixEpochTo()",
|
||||
Result: "SELECT * FROM metrics WHERE epoch <= 1705316400",
|
||||
},
|
||||
"$__unixEpochNanoFilter": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE $__unixEpochNanoFilter(ns_col)",
|
||||
Result: "SELECT * FROM metrics WHERE ns_col >= 1705312800000000000 AND ns_col <= 1705316400000000000",
|
||||
},
|
||||
"$__unixEpochNanoFrom": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE ns >= $__unixEpochNanoFrom()",
|
||||
Result: "SELECT * FROM metrics WHERE ns >= 1705312800000000000",
|
||||
},
|
||||
"$__unixEpochNanoTo": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE ns <= $__unixEpochNanoTo()",
|
||||
Result: "SELECT * FROM metrics WHERE ns <= 1705316400000000000",
|
||||
},
|
||||
"$__unixEpochGroup with 5m": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__unixEpochGroup(epoch_col,'5m'), avg(value) FROM metrics GROUP BY 1",
|
||||
Result: "SELECT floor(epoch_col/300)*300, avg(value) FROM metrics GROUP BY 1",
|
||||
},
|
||||
"$__unixEpochGroup with fill mode": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__unixEpochGroup(epoch_col,'5m', 0) FROM metrics",
|
||||
Result: "SELECT floor(epoch_col/300)*300 FROM metrics",
|
||||
},
|
||||
"$__unixEpochGroupAlias with 5m": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__unixEpochGroupAlias(epoch_col,'5m'), avg(value) FROM metrics GROUP BY 1",
|
||||
Result: "SELECT floor(epoch_col/300)*300 AS `time`, avg(value) FROM metrics GROUP BY 1",
|
||||
},
|
||||
"Unknown macro is left unchanged": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__unknown(col) FROM metrics",
|
||||
Result: "SELECT $__unknown(col) FROM metrics",
|
||||
},
|
||||
"No macros leaves query unchanged": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT time, value FROM metrics WHERE time > NOW() - INTERVAL 1 HOUR",
|
||||
Result: "SELECT time, value FROM metrics WHERE time > NOW() - INTERVAL 1 HOUR",
|
||||
},
|
||||
"Multiple macros in one query": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__timeGroupAlias(created_at,'5m'), avg(value) FROM metrics WHERE $__timeFilter(created_at) GROUP BY 1",
|
||||
Result: "SELECT FLOOR(UNIX_TIMESTAMP(created_at)/300)*300 AS `time`, avg(value) FROM metrics WHERE created_at BETWEEN '2024-01-15T10:00:00Z' AND '2024-01-15T11:00:00Z' GROUP BY 1",
|
||||
},
|
||||
"$__timeGroupAlias with $__interval": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: `SELECT
|
||||
$__timeGroupAlias(time, $__interval),
|
||||
service,
|
||||
COUNT(*) AS deployments
|
||||
FROM deployments
|
||||
WHERE $__timeFilter(time)
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2`,
|
||||
Result: `SELECT
|
||||
FLOOR(UNIX_TIMESTAMP(time)/60)*60 AS ` + "`time`" + `,
|
||||
service,
|
||||
COUNT(*) AS deployments
|
||||
FROM deployments
|
||||
WHERE time BETWEEN '2024-01-15T10:00:00Z' AND '2024-01-15T11:00:00Z'
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2`,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := tc.TimeRange.ProcessMySQLQuery(tc.Query)
|
||||
assert.Equal(t, tc.Result, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
301
server/core/internal/datasource/processor/mysql_test.go
Normal file
301
server/core/internal/datasource/processor/mysql_test.go
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_MySQLQueryResult_Transform(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
Result MySQLQueryResult
|
||||
ChartType ChartType
|
||||
Expected QueryResult
|
||||
}{
|
||||
"Empty chart type returns type-not-selected": {
|
||||
Result: MySQLQueryResult{
|
||||
Columns: []string{"time", "value"},
|
||||
Rows: [][]any{{int64(1700000000), float64(42)}},
|
||||
},
|
||||
ChartType: "",
|
||||
Expected: QueryResult{Status: QueryStatusTypeNotSelected},
|
||||
},
|
||||
"No rows returns no-data": {
|
||||
Result: MySQLQueryResult{
|
||||
Columns: []string{"time", "value"},
|
||||
Rows: nil,
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{Status: QueryStatusNoData},
|
||||
},
|
||||
"No columns returns no-data": {
|
||||
Result: MySQLQueryResult{
|
||||
Columns: nil,
|
||||
Rows: [][]any{{int64(1700000000), float64(42)}},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{Status: QueryStatusNoData},
|
||||
},
|
||||
"No time column returns chart-and-data-mismatch": {
|
||||
Result: MySQLQueryResult{
|
||||
Columns: []string{"name", "value"},
|
||||
Rows: [][]any{{"foo", float64(42)}},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{Status: QueryStatusChartAndDataMismatch},
|
||||
},
|
||||
"No numeric column returns chart-and-data-mismatch": {
|
||||
Result: MySQLQueryResult{
|
||||
Columns: []string{"time", "name"},
|
||||
Rows: [][]any{{int64(1700000000), "foo"}},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{Status: QueryStatusChartAndDataMismatch},
|
||||
},
|
||||
"Single value column with int64 timestamps": {
|
||||
Result: MySQLQueryResult{
|
||||
Columns: []string{"time", "value"},
|
||||
Rows: [][]any{
|
||||
{int64(1700000000), float64(10)},
|
||||
{int64(1700000060), float64(20)},
|
||||
{int64(1700000120), float64(30)},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{},
|
||||
Metrics: [][2]any{
|
||||
{int64(1700000000), float64(10)},
|
||||
{int64(1700000060), float64(20)},
|
||||
{int64(1700000120), float64(30)},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Single value column with float64 timestamps": {
|
||||
Result: MySQLQueryResult{
|
||||
Columns: []string{"time", "value"},
|
||||
Rows: [][]any{
|
||||
{float64(1700000000), float64(10)},
|
||||
{float64(1700000060), float64(20)},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{},
|
||||
Metrics: [][2]any{
|
||||
{int64(1700000000), float64(10)},
|
||||
{int64(1700000060), float64(20)},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Single value column with []byte numeric values (DECIMAL)": {
|
||||
Result: MySQLQueryResult{
|
||||
Columns: []string{"time", "value"},
|
||||
Rows: [][]any{
|
||||
{int64(1700000000), []byte("10.5")},
|
||||
{int64(1700000060), []byte("20.3")},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{},
|
||||
Metrics: [][2]any{
|
||||
{int64(1700000000), 10.5},
|
||||
{int64(1700000060), 20.3},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Single value column with labels": {
|
||||
Result: MySQLQueryResult{
|
||||
Columns: []string{"time", "host", "value"},
|
||||
Rows: [][]any{
|
||||
{int64(1700000000), "host-a", float64(10)},
|
||||
{int64(1700000000), "host-b", float64(20)},
|
||||
{int64(1700000060), "host-a", float64(15)},
|
||||
{int64(1700000060), "host-b", float64(25)},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{"host": "host-a"},
|
||||
Metrics: [][2]any{
|
||||
{int64(1700000000), float64(10)},
|
||||
{int64(1700000060), float64(15)},
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: map[string]string{"host": "host-b"},
|
||||
Metrics: [][2]any{
|
||||
{int64(1700000000), float64(20)},
|
||||
{int64(1700000060), float64(25)},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Multiple value columns without labels": {
|
||||
Result: MySQLQueryResult{
|
||||
Columns: []string{"time", "cpu", "memory"},
|
||||
Rows: [][]any{
|
||||
{int64(1700000000), float64(0.41), float64(0.67)},
|
||||
{int64(1700000060), float64(0.43), float64(0.65)},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{"__name__": "cpu"},
|
||||
Metrics: [][2]any{
|
||||
{int64(1700000000), 0.41},
|
||||
{int64(1700000060), 0.43},
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: map[string]string{"__name__": "memory"},
|
||||
Metrics: [][2]any{
|
||||
{int64(1700000000), 0.67},
|
||||
{int64(1700000060), 0.65},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Multiple value columns with labels": {
|
||||
Result: MySQLQueryResult{
|
||||
Columns: []string{"time", "cpu", "memory", "hostname"},
|
||||
Rows: [][]any{
|
||||
{int64(1700000000), float64(0.41), float64(0.67), "web-1"},
|
||||
{int64(1700000060), float64(0.43), float64(0.65), "web-1"},
|
||||
{int64(1700000000), float64(0.55), float64(0.71), "web-2"},
|
||||
{int64(1700000060), float64(0.51), float64(0.70), "web-2"},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{"__name__": "cpu", "hostname": "web-1"},
|
||||
Metrics: [][2]any{{int64(1700000000), 0.41}, {int64(1700000060), 0.43}},
|
||||
},
|
||||
{
|
||||
Labels: map[string]string{"__name__": "memory", "hostname": "web-1"},
|
||||
Metrics: [][2]any{{int64(1700000000), 0.67}, {int64(1700000060), 0.65}},
|
||||
},
|
||||
{
|
||||
Labels: map[string]string{"__name__": "cpu", "hostname": "web-2"},
|
||||
Metrics: [][2]any{{int64(1700000000), 0.55}, {int64(1700000060), 0.51}},
|
||||
},
|
||||
{
|
||||
Labels: map[string]string{"__name__": "memory", "hostname": "web-2"},
|
||||
Metrics: [][2]any{{int64(1700000000), 0.71}, {int64(1700000060), 0.70}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Gauge keeps only last value per series": {
|
||||
Result: MySQLQueryResult{
|
||||
Columns: []string{"time", "cpu", "memory"},
|
||||
Rows: [][]any{
|
||||
{int64(1700000000), float64(0.41), float64(0.67)},
|
||||
{int64(1700000060), float64(0.43), float64(0.65)},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeGauge,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{"__name__": "cpu"},
|
||||
Metrics: [][2]any{{int64(1700000060), 0.43}},
|
||||
},
|
||||
{
|
||||
Labels: map[string]string{"__name__": "memory"},
|
||||
Metrics: [][2]any{{int64(1700000060), 0.65}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Single value column does not add __name__ label": {
|
||||
Result: MySQLQueryResult{
|
||||
Columns: []string{"time", "count"},
|
||||
Rows: [][]any{{int64(1700000000), float64(42)}},
|
||||
},
|
||||
ChartType: ChartTypeBar,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{},
|
||||
Metrics: [][2]any{{int64(1700000000), float64(42)}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Skips rows with invalid values": {
|
||||
Result: MySQLQueryResult{
|
||||
Columns: []string{"time", "value"},
|
||||
Rows: [][]any{
|
||||
{int64(1700000000), float64(10)},
|
||||
{int64(1700000060), "not-a-number"},
|
||||
{int64(1700000120), float64(30)},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{},
|
||||
Metrics: [][2]any{
|
||||
{int64(1700000000), float64(10)},
|
||||
{int64(1700000120), float64(30)},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"All invalid timestamps returns no-data": {
|
||||
Result: MySQLQueryResult{
|
||||
Columns: []string{"time", "value"},
|
||||
Rows: [][]any{
|
||||
{"bad-time", float64(10)},
|
||||
{"bad-time", float64(20)},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{Status: QueryStatusNoData},
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := tc.Result.Transform(tc.ChartType)
|
||||
require.Equal(t, tc.Expected.Status, result.Status)
|
||||
assert.Equal(t, tc.Expected.Data, result.Data)
|
||||
})
|
||||
}
|
||||
}
|
||||
536
server/core/internal/datasource/processor/postgresql.go
Normal file
536
server/core/internal/datasource/processor/postgresql.go
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
const (
|
||||
// _queryTimeout is the maximum duration for a PostgreSQL query.
|
||||
_queryTimeout = 10 * time.Second
|
||||
|
||||
// _queryPayloadLimit is the maximum estimated payload size (in bytes) for a query result.
|
||||
_queryPayloadLimit = 5 * 1024 * 1024 // 5 MB
|
||||
)
|
||||
|
||||
// PostgreSQL represents a PostgreSQL data source processor.
|
||||
type PostgreSQL struct {
|
||||
inp Input
|
||||
}
|
||||
|
||||
// NewPostgreSQL creates a new PostgreSQL data source processor.
|
||||
func NewPostgreSQL(inp Input) *PostgreSQL {
|
||||
return &PostgreSQL{
|
||||
inp: inp,
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnection tests the connection to the PostgreSQL data source.
|
||||
func (p *PostgreSQL) TestConnection(ctx context.Context) (ConnectionStatus, error) {
|
||||
conn, err := p.connect(ctx)
|
||||
if err != nil {
|
||||
return ConnectionStatusUnreachable, nil
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
|
||||
if err := conn.Ping(ctx); err != nil {
|
||||
return ConnectionStatusUnreachable, nil
|
||||
}
|
||||
|
||||
var readOnly bool
|
||||
|
||||
err = conn.QueryRow(ctx,
|
||||
"SELECT current_setting('default_transaction_read_only')::bool OR pg_is_in_recovery()",
|
||||
).Scan(&readOnly)
|
||||
if err != nil {
|
||||
return ConnectionStatusUnreachable, nil
|
||||
}
|
||||
|
||||
if !readOnly {
|
||||
return ConnectionStatusNotReadOnly, nil
|
||||
}
|
||||
|
||||
return ConnectionStatusSuccess, nil
|
||||
}
|
||||
|
||||
// Metadata retrieves all tables and their columns from the PostgreSQL data source.
|
||||
func (p *PostgreSQL) Metadata(ctx context.Context) (*SQLMetadataResult, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, _queryTimeout)
|
||||
defer cancel()
|
||||
|
||||
conn, err := p.connect(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error connecting to postgresql: %w", err)
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
|
||||
rows, err := conn.Query(ctx,
|
||||
"SELECT table_schema, table_name, column_name FROM information_schema.columns WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name, ordinal_position",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching metadata: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tables := make(map[string]SQLTable)
|
||||
|
||||
for rows.Next() {
|
||||
var schema, tableName, columnName string
|
||||
|
||||
if err := rows.Scan(&schema, &tableName, &columnName); err != nil {
|
||||
return nil, fmt.Errorf("error scanning metadata: %w", err)
|
||||
}
|
||||
|
||||
key := schema + "." + tableName
|
||||
|
||||
table := tables[key]
|
||||
table.Columns = append(table.Columns, SQLColumn{Name: columnName})
|
||||
tables[key] = table
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating metadata: %w", err)
|
||||
}
|
||||
|
||||
return &SQLMetadataResult{
|
||||
Tables: tables,
|
||||
DefaultSchema: "public",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryLabels executes a SQL query with LIMIT 1 against the PostgreSQL data source
|
||||
// and returns the string (label) columns with their example values.
|
||||
func (p *PostgreSQL) QueryLabels(ctx context.Context, q string, tr TimeRange) (map[string]string, error) {
|
||||
q = tr.ProcessPostgreSQLQuery(q)
|
||||
q = sqlSetLimit(q, 1)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, _queryTimeout)
|
||||
defer cancel()
|
||||
|
||||
conn, err := p.connect(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error connecting to postgresql: %w", err)
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
|
||||
rows, err := conn.Query(ctx, q)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
|
||||
if errors.As(err, &pgErr) && pgErr.Code[0:2] == "42" {
|
||||
return nil, NewInvalidQueryError(pgErr.Message)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("error executing query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns := make([]string, len(rows.FieldDescriptions()))
|
||||
for i, fd := range rows.FieldDescriptions() {
|
||||
columns[i] = fd.Name
|
||||
}
|
||||
|
||||
labels := make(map[string]string)
|
||||
|
||||
if !rows.Next() {
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
values, err := rows.Values()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading row values: %w", err)
|
||||
}
|
||||
|
||||
for i, v := range values {
|
||||
if s, ok := v.(string); ok {
|
||||
labels[columns[i]] = s
|
||||
}
|
||||
}
|
||||
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
// sqlSetLimit replaces or appends a LIMIT clause in the query to the given value.
|
||||
func sqlSetLimit(q string, n int) string {
|
||||
re := regexp.MustCompile(`(?i)\bLIMIT\s+\d+\s*$`)
|
||||
|
||||
limit := fmt.Sprintf("LIMIT %d", n)
|
||||
|
||||
if re.MatchString(strings.TrimSpace(q)) {
|
||||
return re.ReplaceAllString(q, limit)
|
||||
}
|
||||
|
||||
q = strings.TrimRight(q, "; \t\n")
|
||||
|
||||
return q + " " + limit
|
||||
}
|
||||
|
||||
// Query executes a SQL query against the PostgreSQL data source.
|
||||
func (p *PostgreSQL) Query(ctx context.Context, q string, tr TimeRange) (*PostgreSQLQueryResult, error) {
|
||||
q = tr.ProcessPostgreSQLQuery(q)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, _queryTimeout)
|
||||
defer cancel()
|
||||
|
||||
conn, err := p.connect(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error connecting to postgresql: %w", err)
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
|
||||
rows, err := conn.Query(ctx, q)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
|
||||
if errors.As(err, &pgErr) && pgErr.Code[0:2] == "42" {
|
||||
return nil, NewInvalidQueryError(pgErr.Message)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("error executing query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns := make([]string, len(rows.FieldDescriptions()))
|
||||
for i, fd := range rows.FieldDescriptions() {
|
||||
columns[i] = fd.Name
|
||||
}
|
||||
|
||||
var (
|
||||
resultRows [][]any
|
||||
payloadSize int
|
||||
)
|
||||
|
||||
for rows.Next() {
|
||||
values, err := rows.Values()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading row values: %w", err)
|
||||
}
|
||||
|
||||
for _, v := range values {
|
||||
payloadSize += pgEstimateValueSize(v)
|
||||
}
|
||||
|
||||
if payloadSize > _queryPayloadLimit {
|
||||
return nil, NewInvalidQueryError("query result exceeds the maximum payload size")
|
||||
}
|
||||
|
||||
resultRows = append(resultRows, values)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating rows: %w", err)
|
||||
}
|
||||
|
||||
return &PostgreSQLQueryResult{
|
||||
Columns: columns,
|
||||
Rows: resultRows,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// connect creates a pgx connection using the data source URL and credentials.
|
||||
func (p *PostgreSQL) connect(ctx context.Context) (*pgx.Conn, error) {
|
||||
connStr, err := p.buildConnectionString()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn, err := pgx.Connect(ctx, connStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error connecting to postgresql: %w", err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// buildConnectionString builds a PostgreSQL connection string from the URL and credentials.
|
||||
func (p *PostgreSQL) buildConnectionString() (string, error) {
|
||||
u, err := url.Parse(p.inp.URL())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error parsing postgresql url: %w", err)
|
||||
}
|
||||
|
||||
if p.inp.Credentials() != nil {
|
||||
var creds PostgreSQLCredentials
|
||||
|
||||
if err := json.Unmarshal(p.inp.Credentials(), &creds); err != nil {
|
||||
return "", fmt.Errorf("error unmarshaling credentials: %w", err)
|
||||
}
|
||||
|
||||
if creds.Username != "" || creds.Password != "" {
|
||||
u.User = url.UserPassword(creds.Username, creds.Password)
|
||||
}
|
||||
}
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// UpdatePostgreSQLCredentials updates the credentials for the PostgreSQL data source.
|
||||
func UpdatePostgreSQLCredentials(rawCreds Credentials, inp CredentialsUpdateInput) (Credentials, error) {
|
||||
var creds PostgreSQLCredentials
|
||||
|
||||
if rawCreds != nil {
|
||||
if err := json.Unmarshal(rawCreds, &creds); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling credentials: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var update PostgreSQLCredentialsUpdate
|
||||
|
||||
if err := json.Unmarshal(inp, &update); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling credentials update input: %w", err)
|
||||
}
|
||||
|
||||
if update.Username.Valid {
|
||||
creds.Username = update.Username.String
|
||||
}
|
||||
|
||||
if update.Password.Valid {
|
||||
creds.Password = update.Password.String
|
||||
}
|
||||
|
||||
if creds.Username == "" && creds.Password == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshaling updated credentials: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// PostgreSQLCredentials represents the credentials for a PostgreSQL data source.
|
||||
type PostgreSQLCredentials struct {
|
||||
// Username is the username for the PostgreSQL data source.
|
||||
Username string `json:"username"`
|
||||
|
||||
// Password is the password for the PostgreSQL data source.
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// PostgreSQLCredentialsUpdate represents the input for updating PostgreSQL credentials.
|
||||
type PostgreSQLCredentialsUpdate struct {
|
||||
// Username is the username for the PostgreSQL data source.
|
||||
Username null.String `json:"username"`
|
||||
|
||||
// Password is the password for the PostgreSQL data source.
|
||||
Password null.String `json:"password"`
|
||||
}
|
||||
|
||||
// PostgreSQLQueryResult represents the result of a PostgreSQL query.
|
||||
type PostgreSQLQueryResult struct {
|
||||
// Columns contains the column names of the result set.
|
||||
Columns []string `json:"columns"`
|
||||
|
||||
// Rows contains the result rows, each as a slice of values.
|
||||
Rows [][]any `json:"rows"`
|
||||
}
|
||||
|
||||
// Transform transforms a PostgreSQLQueryResult into a unified QueryResult
|
||||
// based on the requested chart type.
|
||||
//
|
||||
// The SQL query is expected to return:
|
||||
// - A time column named "time" (as RFC3339 string or unix seconds)
|
||||
// - One or more numeric columns, each producing a separate series
|
||||
// - Any non-numeric, non-time columns are treated as series labels
|
||||
//
|
||||
// Each numeric column generates its own set of series. When there are multiple
|
||||
// numeric columns, the column name is added as the "__name__" label to distinguish them.
|
||||
func (pqr *PostgreSQLQueryResult) Transform(ct ChartType) *QueryResult {
|
||||
if ct == "" {
|
||||
return &QueryResult{Status: QueryStatusTypeNotSelected}
|
||||
}
|
||||
|
||||
if len(pqr.Columns) == 0 || len(pqr.Rows) == 0 {
|
||||
return &QueryResult{Status: QueryStatusNoData}
|
||||
}
|
||||
|
||||
timeIdx, valueIdxs, labelIdxs := pqr.identifyColumns()
|
||||
if timeIdx < 0 || len(valueIdxs) == 0 {
|
||||
return &QueryResult{Status: QueryStatusChartAndDataMismatch}
|
||||
}
|
||||
|
||||
multipleValues := len(valueIdxs) > 1
|
||||
|
||||
// Group rows by (value column, label combination) into series, preserving insertion order.
|
||||
seriesMap := make(map[string]*QueryResultSeries)
|
||||
var seriesOrder []string
|
||||
|
||||
for _, row := range pqr.Rows {
|
||||
if len(row) <= timeIdx {
|
||||
continue
|
||||
}
|
||||
|
||||
ts, ok := pgParseTimestamp(row[timeIdx])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build label key once per row (shared across value columns).
|
||||
labels := make(map[string]string, len(labelIdxs))
|
||||
var labelKeyParts []string
|
||||
|
||||
for _, li := range labelIdxs {
|
||||
var lbl string
|
||||
if li < len(row) && row[li] != nil {
|
||||
lbl = fmt.Sprintf("%v", row[li])
|
||||
}
|
||||
|
||||
labels[pqr.Columns[li]] = lbl
|
||||
labelKeyParts = append(labelKeyParts, pqr.Columns[li]+"="+lbl)
|
||||
}
|
||||
|
||||
labelKey := strings.Join(labelKeyParts, "\x00")
|
||||
|
||||
// Create a data point for each numeric value column.
|
||||
for _, vi := range valueIdxs {
|
||||
if vi >= len(row) {
|
||||
continue
|
||||
}
|
||||
|
||||
val, ok := pgParseNumericValue(row[vi])
|
||||
if !ok || !isValidValue(val) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build series key: value column name + label key.
|
||||
key := pqr.Columns[vi] + "\x00" + labelKey
|
||||
|
||||
if _, exists := seriesMap[key]; !exists {
|
||||
seriesLabels := make(map[string]string, len(labels)+1)
|
||||
for k, v := range labels {
|
||||
seriesLabels[k] = v
|
||||
}
|
||||
|
||||
if multipleValues {
|
||||
seriesLabels["__name__"] = pqr.Columns[vi]
|
||||
}
|
||||
|
||||
seriesMap[key] = &QueryResultSeries{
|
||||
Labels: seriesLabels,
|
||||
}
|
||||
seriesOrder = append(seriesOrder, key)
|
||||
}
|
||||
|
||||
seriesMap[key].Metrics = append(seriesMap[key].Metrics, [2]any{ts, val})
|
||||
}
|
||||
}
|
||||
|
||||
if len(seriesMap) == 0 {
|
||||
return &QueryResult{Status: QueryStatusNoData}
|
||||
}
|
||||
|
||||
series := make([]QueryResultSeries, 0, len(seriesOrder))
|
||||
|
||||
for _, key := range seriesOrder {
|
||||
s := seriesMap[key]
|
||||
|
||||
if ct == ChartTypeGauge && len(s.Metrics) > 0 {
|
||||
// For gauge, only keep the last value.
|
||||
s.Metrics = s.Metrics[len(s.Metrics)-1:]
|
||||
}
|
||||
|
||||
series = append(series, *s)
|
||||
}
|
||||
|
||||
return &QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: series,
|
||||
}
|
||||
}
|
||||
|
||||
// identifyColumns detects which columns are time, value (numeric), and labels (non-numeric).
|
||||
func (pqr *PostgreSQLQueryResult) identifyColumns() (timeIdx int, valueIdxs []int, labelIdxs []int) {
|
||||
timeIdx = -1
|
||||
|
||||
// Identify time column by name.
|
||||
for i, col := range pqr.Columns {
|
||||
if strings.ToLower(col) == "time" {
|
||||
timeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(pqr.Rows) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Classify remaining columns by inspecting the first row's values.
|
||||
firstRow := pqr.Rows[0]
|
||||
|
||||
for i := range pqr.Columns {
|
||||
if i == timeIdx {
|
||||
continue
|
||||
}
|
||||
|
||||
if i < len(firstRow) {
|
||||
if _, ok := firstRow[i].(float64); ok {
|
||||
valueIdxs = append(valueIdxs, i)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
labelIdxs = append(labelIdxs, i)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// pgParseTimestamp parses a timestamp value from a PostgreSQL result row.
|
||||
// It handles float64 (unix seconds) and string (RFC3339) values.
|
||||
func pgParseTimestamp(v any) (int64, bool) {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return int64(val), true
|
||||
case string:
|
||||
t, err := time.Parse(time.RFC3339Nano, val)
|
||||
if err != nil {
|
||||
t, err = time.Parse(time.RFC3339, val)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
return t.Unix(), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// pgParseNumericValue parses a numeric value from a PostgreSQL result row.
|
||||
func pgParseNumericValue(v any) (float64, bool) {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// pgEstimateValueSize returns a rough byte-size estimate for a single value
|
||||
// as it would appear in a JSON-encoded response.
|
||||
func pgEstimateValueSize(v any) int {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
return 4 // "null"
|
||||
case bool:
|
||||
return 5 // "false"
|
||||
case string:
|
||||
return len(val) + 2
|
||||
case []byte:
|
||||
return len(val)
|
||||
default:
|
||||
return 8 // numeric types
|
||||
}
|
||||
}
|
||||
260
server/core/internal/datasource/processor/postgresql_macros.go
Normal file
260
server/core/internal/datasource/processor/postgresql_macros.go
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// _pgMacroRe matches PostgreSQL macro invocations: $__macroName(args...).
|
||||
var _pgMacroRe = regexp.MustCompile(`\$__(\w+)\(([^)]*)\)`)
|
||||
|
||||
// ProcessPostgreSQLQuery processes the query string to replace PostgreSQL-specific
|
||||
// macros with appropriate SQL expressions.
|
||||
//
|
||||
// Supported macros:
|
||||
//
|
||||
// $__time(dateColumn) → EXTRACT(EPOCH FROM dateColumn) AS "time"
|
||||
// $__timeEpoch(dateColumn) → EXTRACT(EPOCH FROM dateColumn) AS "time"
|
||||
// $__timeFilter(dateColumn) → dateColumn BETWEEN 'from'::timestamptz AND 'to'::timestamptz
|
||||
// $__timeFrom() → 'from'::timestamptz
|
||||
// $__timeTo() → 'to'::timestamptz
|
||||
// $__timeGroup(dateColumn,'5m'[, fill]) → floor(EXTRACT(EPOCH FROM dateColumn)/300)*300
|
||||
// $__timeGroupAlias(dateColumn,'5m'[, fill]) → floor(EXTRACT(EPOCH FROM dateColumn)/300)*300 AS "time"
|
||||
// $__unixEpochFilter(dateColumn) → dateColumn >= unixFrom AND dateColumn <= unixTo
|
||||
// $__unixEpochFrom() → unix seconds
|
||||
// $__unixEpochTo() → unix seconds
|
||||
// $__unixEpochNanoFilter(dateColumn) → dateColumn >= nanoFrom AND dateColumn <= nanoTo
|
||||
// $__unixEpochNanoFrom() → unix nanoseconds
|
||||
// $__unixEpochNanoTo() → unix nanoseconds
|
||||
// $__unixEpochGroup(dateColumn,'5m'[, fill]) → floor(dateColumn/300)*300
|
||||
// $__unixEpochGroupAlias(dateColumn,'5m'[, fill]) → floor(dateColumn/300)*300 AS "time"
|
||||
func (tr TimeRange) ProcessPostgreSQLQuery(q string) string {
|
||||
// Resolve generic macros first so they can be used as macro arguments
|
||||
// (e.g., $__timeGroupAlias("time", $__interval)).
|
||||
q = tr.ProcessQuery(q)
|
||||
|
||||
q = _pgMacroRe.ReplaceAllStringFunc(q, func(match string) string {
|
||||
submatch := _pgMacroRe.FindStringSubmatch(match)
|
||||
if len(submatch) != 3 {
|
||||
return match
|
||||
}
|
||||
|
||||
name := submatch[1]
|
||||
args := parseMacroArgs(submatch[2])
|
||||
|
||||
switch name {
|
||||
case "time", "timeEpoch":
|
||||
return pgMacroTime(args, match)
|
||||
case "timeFilter":
|
||||
return pgMacroTimeFilter(tr, args, match)
|
||||
case "timeFrom":
|
||||
return pgMacroTimeFrom(tr)
|
||||
case "timeTo":
|
||||
return pgMacroTimeTo(tr)
|
||||
case "timeGroup":
|
||||
return pgMacroTimeGroup(args, match)
|
||||
case "timeGroupAlias":
|
||||
return pgMacroTimeGroupAlias(args, match)
|
||||
case "unixEpochFilter":
|
||||
return pgMacroUnixEpochFilter(tr, args, match)
|
||||
case "unixEpochFrom":
|
||||
return pgMacroUnixEpochFrom(tr)
|
||||
case "unixEpochTo":
|
||||
return pgMacroUnixEpochTo(tr)
|
||||
case "unixEpochNanoFilter":
|
||||
return pgMacroUnixEpochNanoFilter(tr, args, match)
|
||||
case "unixEpochNanoFrom":
|
||||
return pgMacroUnixEpochNanoFrom(tr)
|
||||
case "unixEpochNanoTo":
|
||||
return pgMacroUnixEpochNanoTo(tr)
|
||||
case "unixEpochGroup":
|
||||
return pgMacroUnixEpochGroup(args, match)
|
||||
case "unixEpochGroupAlias":
|
||||
return pgMacroUnixEpochGroupAlias(args, match)
|
||||
default:
|
||||
return match
|
||||
}
|
||||
})
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
// parseMacroArgs parses comma-separated macro arguments, stripping surrounding
|
||||
// whitespace and quotes from each argument.
|
||||
func parseMacroArgs(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
args := make([]string, len(parts))
|
||||
|
||||
for i, p := range parts {
|
||||
args[i] = strings.Trim(strings.TrimSpace(p), "'\"")
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// parseInterval parses an interval string (e.g., "5m", "1h", "30s") to seconds.
|
||||
func parseInterval(s string) (int64, bool) {
|
||||
if len(s) < 2 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
unit := s[len(s)-1]
|
||||
num, err := strconv.ParseInt(s[:len(s)-1], 10, 64)
|
||||
if err != nil || num <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
switch unit {
|
||||
case 's':
|
||||
return num, true
|
||||
case 'm':
|
||||
return num * 60, true
|
||||
case 'h':
|
||||
return num * 3600, true
|
||||
case 'd':
|
||||
return num * 86400, true
|
||||
case 'w':
|
||||
return num * 604800, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// $__time(dateColumn) / $__timeEpoch(dateColumn)
|
||||
func pgMacroTime(args []string, original string) string {
|
||||
if len(args) < 1 {
|
||||
return original
|
||||
}
|
||||
|
||||
return fmt.Sprintf("EXTRACT(EPOCH FROM %s) AS \"time\"", args[0])
|
||||
}
|
||||
|
||||
// $__timeFilter(dateColumn)
|
||||
func pgMacroTimeFilter(tr TimeRange, args []string, original string) string {
|
||||
if len(args) < 1 {
|
||||
return original
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s BETWEEN '%s'::timestamptz AND '%s'::timestamptz",
|
||||
args[0],
|
||||
tr.From.UTC().Format(time.RFC3339),
|
||||
tr.To.UTC().Format(time.RFC3339),
|
||||
)
|
||||
}
|
||||
|
||||
// $__timeFrom()
|
||||
func pgMacroTimeFrom(tr TimeRange) string {
|
||||
return fmt.Sprintf("'%s'::timestamptz", tr.From.UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// $__timeTo()
|
||||
func pgMacroTimeTo(tr TimeRange) string {
|
||||
return fmt.Sprintf("'%s'::timestamptz", tr.To.UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// $__timeGroup(dateColumn,'5m'[, fill])
|
||||
func pgMacroTimeGroup(args []string, original string) string {
|
||||
if len(args) < 2 {
|
||||
return original
|
||||
}
|
||||
|
||||
seconds, ok := parseInterval(args[1])
|
||||
if !ok {
|
||||
return original
|
||||
}
|
||||
|
||||
return fmt.Sprintf("floor(EXTRACT(EPOCH FROM %s)/%d)*%d", args[0], seconds, seconds)
|
||||
}
|
||||
|
||||
// $__timeGroupAlias(dateColumn,'5m'[, fill])
|
||||
func pgMacroTimeGroupAlias(args []string, original string) string {
|
||||
if len(args) < 2 {
|
||||
return original
|
||||
}
|
||||
|
||||
seconds, ok := parseInterval(args[1])
|
||||
if !ok {
|
||||
return original
|
||||
}
|
||||
|
||||
return fmt.Sprintf("floor(EXTRACT(EPOCH FROM %s)/%d)*%d AS \"time\"", args[0], seconds, seconds)
|
||||
}
|
||||
|
||||
// $__unixEpochFilter(dateColumn)
|
||||
func pgMacroUnixEpochFilter(tr TimeRange, args []string, original string) string {
|
||||
if len(args) < 1 {
|
||||
return original
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s >= %d AND %s <= %d",
|
||||
args[0], tr.From.Unix(),
|
||||
args[0], tr.To.Unix(),
|
||||
)
|
||||
}
|
||||
|
||||
// $__unixEpochFrom()
|
||||
func pgMacroUnixEpochFrom(tr TimeRange) string {
|
||||
return strconv.FormatInt(tr.From.Unix(), 10)
|
||||
}
|
||||
|
||||
// $__unixEpochTo()
|
||||
func pgMacroUnixEpochTo(tr TimeRange) string {
|
||||
return strconv.FormatInt(tr.To.Unix(), 10)
|
||||
}
|
||||
|
||||
// $__unixEpochNanoFilter(dateColumn)
|
||||
func pgMacroUnixEpochNanoFilter(tr TimeRange, args []string, original string) string {
|
||||
if len(args) < 1 {
|
||||
return original
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s >= %d AND %s <= %d",
|
||||
args[0], tr.From.UnixNano(),
|
||||
args[0], tr.To.UnixNano(),
|
||||
)
|
||||
}
|
||||
|
||||
// $__unixEpochNanoFrom()
|
||||
func pgMacroUnixEpochNanoFrom(tr TimeRange) string {
|
||||
return strconv.FormatInt(tr.From.UnixNano(), 10)
|
||||
}
|
||||
|
||||
// $__unixEpochNanoTo()
|
||||
func pgMacroUnixEpochNanoTo(tr TimeRange) string {
|
||||
return strconv.FormatInt(tr.To.UnixNano(), 10)
|
||||
}
|
||||
|
||||
// $__unixEpochGroup(dateColumn,'5m'[, fill])
|
||||
func pgMacroUnixEpochGroup(args []string, original string) string {
|
||||
if len(args) < 2 {
|
||||
return original
|
||||
}
|
||||
|
||||
seconds, ok := parseInterval(args[1])
|
||||
if !ok {
|
||||
return original
|
||||
}
|
||||
|
||||
return fmt.Sprintf("floor(%s/%d)*%d", args[0], seconds, seconds)
|
||||
}
|
||||
|
||||
// $__unixEpochGroupAlias(dateColumn,'5m'[, fill])
|
||||
func pgMacroUnixEpochGroupAlias(args []string, original string) string {
|
||||
if len(args) < 2 {
|
||||
return original
|
||||
}
|
||||
|
||||
seconds, ok := parseInterval(args[1])
|
||||
if !ok {
|
||||
return original
|
||||
}
|
||||
|
||||
return fmt.Sprintf("floor(%s/%d)*%d AS \"time\"", args[0], seconds, seconds)
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var _testTimeRange = TimeRange{
|
||||
From: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
func Test_TimeRange_ProcessPostgreSQLQuery(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
TimeRange TimeRange
|
||||
Query string
|
||||
Result string
|
||||
}{
|
||||
"$__time": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__time(created_at), value FROM metrics",
|
||||
Result: `SELECT EXTRACT(EPOCH FROM created_at) AS "time", value FROM metrics`,
|
||||
},
|
||||
"$__timeEpoch": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__timeEpoch(created_at), value FROM metrics",
|
||||
Result: `SELECT EXTRACT(EPOCH FROM created_at) AS "time", value FROM metrics`,
|
||||
},
|
||||
"$__timeFilter": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE $__timeFilter(created_at)",
|
||||
Result: "SELECT * FROM metrics WHERE created_at BETWEEN '2024-01-15T10:00:00Z'::timestamptz AND '2024-01-15T11:00:00Z'::timestamptz",
|
||||
},
|
||||
"$__timeFrom": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE created_at >= $__timeFrom()",
|
||||
Result: "SELECT * FROM metrics WHERE created_at >= '2024-01-15T10:00:00Z'::timestamptz",
|
||||
},
|
||||
"$__timeTo": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE created_at <= $__timeTo()",
|
||||
Result: "SELECT * FROM metrics WHERE created_at <= '2024-01-15T11:00:00Z'::timestamptz",
|
||||
},
|
||||
"$__timeFrom and $__timeTo together": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE created_at >= $__timeFrom() AND created_at <= $__timeTo()",
|
||||
Result: "SELECT * FROM metrics WHERE created_at >= '2024-01-15T10:00:00Z'::timestamptz AND created_at <= '2024-01-15T11:00:00Z'::timestamptz",
|
||||
},
|
||||
"$__timeGroup with 5m": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__timeGroup(created_at,'5m'), avg(value) FROM metrics GROUP BY 1",
|
||||
Result: "SELECT floor(EXTRACT(EPOCH FROM created_at)/300)*300, avg(value) FROM metrics GROUP BY 1",
|
||||
},
|
||||
"$__timeGroup with 1h": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__timeGroup(created_at,'1h') FROM metrics",
|
||||
Result: "SELECT floor(EXTRACT(EPOCH FROM created_at)/3600)*3600 FROM metrics",
|
||||
},
|
||||
"$__timeGroup with fill mode 0": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__timeGroup(created_at,'5m', 0), avg(value) FROM metrics GROUP BY 1",
|
||||
Result: "SELECT floor(EXTRACT(EPOCH FROM created_at)/300)*300, avg(value) FROM metrics GROUP BY 1",
|
||||
},
|
||||
"$__timeGroup with fill mode NULL": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__timeGroup(created_at,'5m', NULL), avg(value) FROM metrics GROUP BY 1",
|
||||
Result: "SELECT floor(EXTRACT(EPOCH FROM created_at)/300)*300, avg(value) FROM metrics GROUP BY 1",
|
||||
},
|
||||
"$__timeGroup with fill mode previous": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__timeGroup(created_at,'5m', previous) FROM metrics",
|
||||
Result: "SELECT floor(EXTRACT(EPOCH FROM created_at)/300)*300 FROM metrics",
|
||||
},
|
||||
"$__timeGroupAlias with 5m": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__timeGroupAlias(created_at,'5m'), avg(value) FROM metrics GROUP BY 1",
|
||||
Result: `SELECT floor(EXTRACT(EPOCH FROM created_at)/300)*300 AS "time", avg(value) FROM metrics GROUP BY 1`,
|
||||
},
|
||||
"$__unixEpochFilter": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE $__unixEpochFilter(epoch_col)",
|
||||
Result: "SELECT * FROM metrics WHERE epoch_col >= 1705312800 AND epoch_col <= 1705316400",
|
||||
},
|
||||
"$__unixEpochFrom": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE epoch >= $__unixEpochFrom()",
|
||||
Result: "SELECT * FROM metrics WHERE epoch >= 1705312800",
|
||||
},
|
||||
"$__unixEpochTo": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE epoch <= $__unixEpochTo()",
|
||||
Result: "SELECT * FROM metrics WHERE epoch <= 1705316400",
|
||||
},
|
||||
"$__unixEpochNanoFilter": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE $__unixEpochNanoFilter(ns_col)",
|
||||
Result: "SELECT * FROM metrics WHERE ns_col >= 1705312800000000000 AND ns_col <= 1705316400000000000",
|
||||
},
|
||||
"$__unixEpochNanoFrom": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE ns >= $__unixEpochNanoFrom()",
|
||||
Result: "SELECT * FROM metrics WHERE ns >= 1705312800000000000",
|
||||
},
|
||||
"$__unixEpochNanoTo": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT * FROM metrics WHERE ns <= $__unixEpochNanoTo()",
|
||||
Result: "SELECT * FROM metrics WHERE ns <= 1705316400000000000",
|
||||
},
|
||||
"$__unixEpochGroup with 5m": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__unixEpochGroup(epoch_col,'5m'), avg(value) FROM metrics GROUP BY 1",
|
||||
Result: "SELECT floor(epoch_col/300)*300, avg(value) FROM metrics GROUP BY 1",
|
||||
},
|
||||
"$__unixEpochGroup with fill mode": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__unixEpochGroup(epoch_col,'5m', 0) FROM metrics",
|
||||
Result: "SELECT floor(epoch_col/300)*300 FROM metrics",
|
||||
},
|
||||
"$__unixEpochGroupAlias with 5m": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__unixEpochGroupAlias(epoch_col,'5m'), avg(value) FROM metrics GROUP BY 1",
|
||||
Result: `SELECT floor(epoch_col/300)*300 AS "time", avg(value) FROM metrics GROUP BY 1`,
|
||||
},
|
||||
"Unknown macro is left unchanged": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__unknown(col) FROM metrics",
|
||||
Result: "SELECT $__unknown(col) FROM metrics",
|
||||
},
|
||||
"No macros leaves query unchanged": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT time, value FROM metrics WHERE time > now() - interval '1 hour'",
|
||||
Result: "SELECT time, value FROM metrics WHERE time > now() - interval '1 hour'",
|
||||
},
|
||||
"Multiple macros in one query": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: "SELECT $__timeGroupAlias(created_at,'5m'), avg(value) FROM metrics WHERE $__timeFilter(created_at) GROUP BY 1",
|
||||
Result: `SELECT floor(EXTRACT(EPOCH FROM created_at)/300)*300 AS "time", avg(value) FROM metrics WHERE created_at BETWEEN '2024-01-15T10:00:00Z'::timestamptz AND '2024-01-15T11:00:00Z'::timestamptz GROUP BY 1`,
|
||||
},
|
||||
"$__timeGroupAlias with $__interval": {
|
||||
TimeRange: _testTimeRange,
|
||||
Query: `SELECT
|
||||
$__timeGroupAlias("time", $__interval),
|
||||
service,
|
||||
COUNT(*)::double precision AS deployments
|
||||
FROM deployments
|
||||
WHERE $__timeFilter("time")
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2`,
|
||||
Result: `SELECT
|
||||
floor(EXTRACT(EPOCH FROM time)/60)*60 AS "time",
|
||||
service,
|
||||
COUNT(*)::double precision AS deployments
|
||||
FROM deployments
|
||||
WHERE time BETWEEN '2024-01-15T10:00:00Z'::timestamptz AND '2024-01-15T11:00:00Z'::timestamptz
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2`,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := tc.TimeRange.ProcessPostgreSQLQuery(tc.Query)
|
||||
assert.Equal(t, tc.Result, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_parseInterval(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
Input string
|
||||
Seconds int64
|
||||
OK bool
|
||||
}{
|
||||
"Seconds": {Input: "30s", Seconds: 30, OK: true},
|
||||
"Minutes": {Input: "5m", Seconds: 300, OK: true},
|
||||
"Hours": {Input: "1h", Seconds: 3600, OK: true},
|
||||
"Days": {Input: "1d", Seconds: 86400, OK: true},
|
||||
"Weeks": {Input: "1w", Seconds: 604800, OK: true},
|
||||
"Invalid unit": {Input: "5x", Seconds: 0, OK: false},
|
||||
"No number": {Input: "m", Seconds: 0, OK: false},
|
||||
"Empty": {Input: "", Seconds: 0, OK: false},
|
||||
"Zero": {Input: "0m", Seconds: 0, OK: false},
|
||||
"Negative": {Input: "-5m", Seconds: 0, OK: false},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
seconds, ok := parseInterval(tc.Input)
|
||||
assert.Equal(t, tc.OK, ok)
|
||||
assert.Equal(t, tc.Seconds, seconds)
|
||||
})
|
||||
}
|
||||
}
|
||||
407
server/core/internal/datasource/processor/postgresql_test.go
Normal file
407
server/core/internal/datasource/processor/postgresql_test.go
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_PostgreSQLQueryResult_Transform(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
Result PostgreSQLQueryResult
|
||||
ChartType ChartType
|
||||
Expected QueryResult
|
||||
}{
|
||||
"Empty chart type returns type-not-selected": {
|
||||
Result: PostgreSQLQueryResult{
|
||||
Columns: []string{"time", "value"},
|
||||
Rows: [][]any{{1700000000.0, 42.0}},
|
||||
},
|
||||
ChartType: "",
|
||||
Expected: QueryResult{Status: QueryStatusTypeNotSelected},
|
||||
},
|
||||
"No rows returns no-data": {
|
||||
Result: PostgreSQLQueryResult{
|
||||
Columns: []string{"time", "value"},
|
||||
Rows: nil,
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{Status: QueryStatusNoData},
|
||||
},
|
||||
"No columns returns no-data": {
|
||||
Result: PostgreSQLQueryResult{
|
||||
Columns: nil,
|
||||
Rows: [][]any{{1700000000.0, 42.0}},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{Status: QueryStatusNoData},
|
||||
},
|
||||
"No time column returns chart-and-data-mismatch": {
|
||||
Result: PostgreSQLQueryResult{
|
||||
Columns: []string{"name", "value"},
|
||||
Rows: [][]any{{"foo", 42.0}},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{Status: QueryStatusChartAndDataMismatch},
|
||||
},
|
||||
"No numeric column returns chart-and-data-mismatch": {
|
||||
Result: PostgreSQLQueryResult{
|
||||
Columns: []string{"time", "name"},
|
||||
Rows: [][]any{{1700000000.0, "foo"}},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{Status: QueryStatusChartAndDataMismatch},
|
||||
},
|
||||
"Single value column": {
|
||||
Result: PostgreSQLQueryResult{
|
||||
Columns: []string{"time", "value"},
|
||||
Rows: [][]any{
|
||||
{1700000000.0, 10.0},
|
||||
{1700000060.0, 20.0},
|
||||
{1700000120.0, 30.0},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{},
|
||||
Metrics: [][2]any{
|
||||
{int64(1700000000), 10.0},
|
||||
{int64(1700000060), 20.0},
|
||||
{int64(1700000120), 30.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"RFC3339 timestamps": {
|
||||
Result: PostgreSQLQueryResult{
|
||||
Columns: []string{"time", "value"},
|
||||
Rows: [][]any{
|
||||
{"2023-11-14T22:13:20Z", 10.0},
|
||||
{"2023-11-14T22:14:20Z", 20.0},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{},
|
||||
Metrics: [][2]any{
|
||||
{int64(1700000000), 10.0},
|
||||
{int64(1700000060), 20.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Single value column with labels": {
|
||||
Result: PostgreSQLQueryResult{
|
||||
Columns: []string{"time", "host", "value"},
|
||||
Rows: [][]any{
|
||||
{1700000000.0, "host-a", 10.0},
|
||||
{1700000000.0, "host-b", 20.0},
|
||||
{1700000060.0, "host-a", 15.0},
|
||||
{1700000060.0, "host-b", 25.0},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{"host": "host-a"},
|
||||
Metrics: [][2]any{
|
||||
{int64(1700000000), 10.0},
|
||||
{int64(1700000060), 15.0},
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: map[string]string{"host": "host-b"},
|
||||
Metrics: [][2]any{
|
||||
{int64(1700000000), 20.0},
|
||||
{int64(1700000060), 25.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Multiple value columns without labels": {
|
||||
Result: PostgreSQLQueryResult{
|
||||
Columns: []string{"time", "cpu", "memory"},
|
||||
Rows: [][]any{
|
||||
{1700000000.0, 0.41, 0.67},
|
||||
{1700000060.0, 0.43, 0.65},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{"__name__": "cpu"},
|
||||
Metrics: [][2]any{
|
||||
{int64(1700000000), 0.41},
|
||||
{int64(1700000060), 0.43},
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: map[string]string{"__name__": "memory"},
|
||||
Metrics: [][2]any{
|
||||
{int64(1700000000), 0.67},
|
||||
{int64(1700000060), 0.65},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Multiple value columns with labels": {
|
||||
Result: PostgreSQLQueryResult{
|
||||
Columns: []string{"time", "cpu", "memory", "hostname"},
|
||||
Rows: [][]any{
|
||||
{1700000000.0, 0.41, 0.67, "web-1"},
|
||||
{1700000060.0, 0.43, 0.65, "web-1"},
|
||||
{1700000000.0, 0.55, 0.71, "web-2"},
|
||||
{1700000060.0, 0.51, 0.70, "web-2"},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{"__name__": "cpu", "hostname": "web-1"},
|
||||
Metrics: [][2]any{{int64(1700000000), 0.41}, {int64(1700000060), 0.43}},
|
||||
},
|
||||
{
|
||||
Labels: map[string]string{"__name__": "memory", "hostname": "web-1"},
|
||||
Metrics: [][2]any{{int64(1700000000), 0.67}, {int64(1700000060), 0.65}},
|
||||
},
|
||||
{
|
||||
Labels: map[string]string{"__name__": "cpu", "hostname": "web-2"},
|
||||
Metrics: [][2]any{{int64(1700000000), 0.55}, {int64(1700000060), 0.51}},
|
||||
},
|
||||
{
|
||||
Labels: map[string]string{"__name__": "memory", "hostname": "web-2"},
|
||||
Metrics: [][2]any{{int64(1700000000), 0.71}, {int64(1700000060), 0.70}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Gauge keeps only last value per series": {
|
||||
Result: PostgreSQLQueryResult{
|
||||
Columns: []string{"time", "cpu", "memory"},
|
||||
Rows: [][]any{
|
||||
{1700000000.0, 0.41, 0.67},
|
||||
{1700000060.0, 0.43, 0.65},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeGauge,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{"__name__": "cpu"},
|
||||
Metrics: [][2]any{{int64(1700000060), 0.43}},
|
||||
},
|
||||
{
|
||||
Labels: map[string]string{"__name__": "memory"},
|
||||
Metrics: [][2]any{{int64(1700000060), 0.65}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Single value column does not add __name__ label": {
|
||||
Result: PostgreSQLQueryResult{
|
||||
Columns: []string{"time", "count"},
|
||||
Rows: [][]any{{1700000000.0, 42.0}},
|
||||
},
|
||||
ChartType: ChartTypeBar,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{},
|
||||
Metrics: [][2]any{{int64(1700000000), 42.0}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Skips rows with invalid values": {
|
||||
Result: PostgreSQLQueryResult{
|
||||
Columns: []string{"time", "value"},
|
||||
Rows: [][]any{
|
||||
{1700000000.0, 10.0},
|
||||
{1700000060.0, "not-a-number"},
|
||||
{1700000120.0, 30.0},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{},
|
||||
Metrics: [][2]any{
|
||||
{int64(1700000000), 10.0},
|
||||
{int64(1700000120), 30.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"All invalid timestamps returns no-data": {
|
||||
Result: PostgreSQLQueryResult{
|
||||
Columns: []string{"time", "value"},
|
||||
Rows: [][]any{
|
||||
{"bad-time", 10.0},
|
||||
{"bad-time", 20.0},
|
||||
},
|
||||
},
|
||||
ChartType: ChartTypeLine,
|
||||
Expected: QueryResult{Status: QueryStatusNoData},
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := tc.Result.Transform(tc.ChartType)
|
||||
require.Equal(t, tc.Expected.Status, result.Status)
|
||||
assert.Equal(t, tc.Expected.Data, result.Data)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_sqlSetLimit(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
Query string
|
||||
Limit int
|
||||
Expected string
|
||||
}{
|
||||
"Appends LIMIT to simple query": {
|
||||
Query: "SELECT * FROM users",
|
||||
Limit: 1,
|
||||
Expected: "SELECT * FROM users LIMIT 1",
|
||||
},
|
||||
"Replaces existing LIMIT": {
|
||||
Query: "SELECT * FROM users LIMIT 100",
|
||||
Limit: 1,
|
||||
Expected: "SELECT * FROM users LIMIT 1",
|
||||
},
|
||||
"Replaces existing LIMIT with different value": {
|
||||
Query: "SELECT * FROM users LIMIT 50",
|
||||
Limit: 5,
|
||||
Expected: "SELECT * FROM users LIMIT 5",
|
||||
},
|
||||
"Case insensitive - lowercase limit": {
|
||||
Query: "SELECT * FROM users limit 100",
|
||||
Limit: 1,
|
||||
Expected: "SELECT * FROM users LIMIT 1",
|
||||
},
|
||||
"Case insensitive - mixed case": {
|
||||
Query: "SELECT * FROM users Limit 100",
|
||||
Limit: 1,
|
||||
Expected: "SELECT * FROM users LIMIT 1",
|
||||
},
|
||||
"Strips trailing semicolon": {
|
||||
Query: "SELECT * FROM users;",
|
||||
Limit: 1,
|
||||
Expected: "SELECT * FROM users LIMIT 1",
|
||||
},
|
||||
"Strips trailing semicolon with spaces": {
|
||||
Query: "SELECT * FROM users ; ",
|
||||
Limit: 1,
|
||||
Expected: "SELECT * FROM users LIMIT 1",
|
||||
},
|
||||
"Replaces LIMIT with trailing whitespace": {
|
||||
Query: "SELECT * FROM users LIMIT 100 ",
|
||||
Limit: 1,
|
||||
Expected: "SELECT * FROM users LIMIT 1",
|
||||
},
|
||||
"Does not affect LIMIT inside subquery": {
|
||||
Query: "SELECT * FROM (SELECT id FROM users LIMIT 10) sub",
|
||||
Limit: 1,
|
||||
Expected: "SELECT * FROM (SELECT id FROM users LIMIT 10) sub LIMIT 1",
|
||||
},
|
||||
"Does not affect LIMIT inside subquery in WHERE clause": {
|
||||
Query: "SELECT * FROM orders WHERE user_id IN (SELECT id FROM users LIMIT 5) ORDER BY created_at",
|
||||
Limit: 1,
|
||||
Expected: "SELECT * FROM orders WHERE user_id IN (SELECT id FROM users LIMIT 5) ORDER BY created_at LIMIT 1",
|
||||
},
|
||||
"Replaces outer LIMIT with subquery LIMIT present": {
|
||||
Query: "SELECT * FROM (SELECT id FROM users LIMIT 10) sub LIMIT 50",
|
||||
Limit: 1,
|
||||
Expected: "SELECT * FROM (SELECT id FROM users LIMIT 10) sub LIMIT 1",
|
||||
},
|
||||
"Handles query with ORDER BY": {
|
||||
Query: "SELECT * FROM users ORDER BY name",
|
||||
Limit: 1,
|
||||
Expected: "SELECT * FROM users ORDER BY name LIMIT 1",
|
||||
},
|
||||
"Replaces LIMIT after ORDER BY": {
|
||||
Query: "SELECT * FROM users ORDER BY name LIMIT 25",
|
||||
Limit: 1,
|
||||
Expected: "SELECT * FROM users ORDER BY name LIMIT 1",
|
||||
},
|
||||
"Handles query with GROUP BY": {
|
||||
Query: "SELECT host, COUNT(*) FROM logs GROUP BY host LIMIT 20",
|
||||
Limit: 1,
|
||||
Expected: "SELECT host, COUNT(*) FROM logs GROUP BY host LIMIT 1",
|
||||
},
|
||||
"Handles multiline query": {
|
||||
Query: "SELECT *\nFROM users\nWHERE active = true",
|
||||
Limit: 1,
|
||||
Expected: "SELECT *\nFROM users\nWHERE active = true LIMIT 1",
|
||||
},
|
||||
"Replaces LIMIT in multiline query": {
|
||||
Query: "SELECT *\nFROM users\nLIMIT 100",
|
||||
Limit: 1,
|
||||
Expected: "SELECT *\nFROM users\nLIMIT 1",
|
||||
},
|
||||
"Handles CTE with subquery LIMIT": {
|
||||
Query: "WITH top_users AS (SELECT id FROM users LIMIT 10) SELECT * FROM top_users JOIN orders ON orders.user_id = top_users.id",
|
||||
Limit: 1,
|
||||
Expected: "WITH top_users AS (SELECT id FROM users LIMIT 10) SELECT * FROM top_users JOIN orders ON orders.user_id = top_users.id LIMIT 1",
|
||||
},
|
||||
"Handles OFFSET after LIMIT": {
|
||||
Query: "SELECT * FROM users LIMIT 100 OFFSET 20",
|
||||
Limit: 1,
|
||||
Expected: "SELECT * FROM users LIMIT 100 OFFSET 20 LIMIT 1",
|
||||
},
|
||||
"Handles empty query": {
|
||||
Query: "",
|
||||
Limit: 1,
|
||||
Expected: " LIMIT 1",
|
||||
},
|
||||
"Does not match LIMIT in string literal at end": {
|
||||
Query: "SELECT * FROM users WHERE name = 'LIMIT 100'",
|
||||
Limit: 1,
|
||||
Expected: "SELECT * FROM users WHERE name = 'LIMIT 100' LIMIT 1",
|
||||
},
|
||||
"Does not match NOLIMIT or similar": {
|
||||
Query: "SELECT * FROM users WHERE nolimit = true",
|
||||
Limit: 1,
|
||||
Expected: "SELECT * FROM users WHERE nolimit = true LIMIT 1",
|
||||
},
|
||||
"Multiline query with GROUP BY and ORDER BY": {
|
||||
Query: "SELECT\n\t$__timeGroupAlias(\"time\", \"35m\"),\n\tservice,\n\tCOUNT(*) AS deployments\nFROM deployments\nWHERE $__timeFilter(\"time\")\nGROUP BY 1, 2\nORDER BY 1, 2",
|
||||
Limit: 1,
|
||||
Expected: "SELECT\n\t$__timeGroupAlias(\"time\", \"35m\"),\n\tservice,\n\tCOUNT(*) AS deployments\nFROM deployments\nWHERE $__timeFilter(\"time\")\nGROUP BY 1, 2\nORDER BY 1, 2 LIMIT 1",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := sqlSetLimit(tc.Query, tc.Limit)
|
||||
assert.Equal(t, tc.Expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
101
server/core/internal/datasource/processor/processor.go
Normal file
101
server/core/internal/datasource/processor/processor.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/oxynote/purse/util/cryptoutil"
|
||||
"github.com/oxynote/purse/util/errutil"
|
||||
)
|
||||
|
||||
// ConnectionStatus represents the status of a connection test.
|
||||
type ConnectionStatus string
|
||||
|
||||
const (
|
||||
// ConnectionStatusSuccess indicates that the connection test was successful.
|
||||
ConnectionStatusSuccess ConnectionStatus = "success"
|
||||
|
||||
// ConnectionStatusUnauthorized indicates that the connection test failed due to unauthorized access.
|
||||
ConnectionStatusUnauthorized ConnectionStatus = "unauthorized"
|
||||
|
||||
// ConnectionStatusUnreachable indicates that the data source is unreachable.
|
||||
ConnectionStatusUnreachable ConnectionStatus = "unreachable"
|
||||
|
||||
// ConnectionStatusVersionNotSupported indicates that the data source version is not supported.
|
||||
ConnectionStatusVersionNotSupported ConnectionStatus = "version_not_supported"
|
||||
|
||||
// ConnectionStatusNotReadOnly indicates that the connection is not read-only.
|
||||
ConnectionStatusNotReadOnly ConnectionStatus = "not_read_only"
|
||||
)
|
||||
|
||||
// Error returns an error corresponding to the ConnectionStatus.
|
||||
func (cs ConnectionStatus) Error() error {
|
||||
switch cs {
|
||||
case ConnectionStatusUnauthorized:
|
||||
return errutil.New(http.StatusUnauthorized, "data_source.unauthorized", "Unauthorized access to the data source.")
|
||||
case ConnectionStatusUnreachable:
|
||||
return errutil.New(http.StatusBadRequest, "data_source.unreachable", "The data source is unreachable.")
|
||||
case ConnectionStatusVersionNotSupported:
|
||||
return errutil.New(http.StatusBadRequest, "data_source.version_not_supported", "The data source version is not supported.")
|
||||
case ConnectionStatusNotReadOnly:
|
||||
return errutil.New(http.StatusBadRequest, "data_source.not_read_only", "The data source connection must be read-only.")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Input represents the input required by a data source processor.
|
||||
type Input interface {
|
||||
// URL returns the endpoint URL of the data source.
|
||||
URL() string
|
||||
|
||||
// Credentials returns the credentials associated with the data source.
|
||||
Credentials() Credentials
|
||||
}
|
||||
|
||||
// Credentials represents the credentials of a data source in JSON format.
|
||||
type Credentials []byte
|
||||
|
||||
// MarshalJSON returns the JSON representation of the credentials.
|
||||
func (c *Credentials) MarshalJSON() ([]byte, error) {
|
||||
return []byte(*c), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON sets the credentials from the given JSON data.
|
||||
func (c *Credentials) UnmarshalJSON(data []byte) error {
|
||||
*c = data
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts the credentials using the provided signing key.
|
||||
func (c Credentials) Encrypt(signingKey string) ([]byte, error) {
|
||||
state, err := cryptoutil.EncryptText(string(c), []byte(signingKey))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt credentials: %w", err)
|
||||
}
|
||||
|
||||
return []byte(state), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts the given encrypted credentials using the provided signing key.
|
||||
func (c *Credentials) Decrypt(signingKey string) error {
|
||||
decrypted, err := cryptoutil.DecryptText(string(*c), []byte(signingKey))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt credentials: %w", err)
|
||||
}
|
||||
|
||||
*c = Credentials(decrypted)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CredentialsUpdateInput represents the input for updating credentials.
|
||||
type CredentialsUpdateInput []byte
|
||||
|
||||
// UnmarshalJSON sets the credentials from the given JSON data.
|
||||
func (cui *CredentialsUpdateInput) UnmarshalJSON(data []byte) error {
|
||||
*cui = data
|
||||
|
||||
return nil
|
||||
}
|
||||
553
server/core/internal/datasource/processor/prometheus.go
Normal file
553
server/core/internal/datasource/processor/prometheus.go
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"github.com/prometheus/client_golang/api"
|
||||
v1 "github.com/prometheus/client_golang/api/prometheus/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
// _queryLimit defines the maximum number of data points to return in a query.
|
||||
_queryLimit = 1000
|
||||
|
||||
// _metadataLimit defines the maximum number of metadata entries to return.
|
||||
_metadataLimit = "1000"
|
||||
|
||||
// _labelLimit defines the maximum number of labels to return.
|
||||
_labelLimit = 100
|
||||
)
|
||||
|
||||
// Prometheus represents a Prometheus data source processor.
|
||||
type Prometheus struct {
|
||||
inp Input
|
||||
}
|
||||
|
||||
// NewPrometheus creates a new Prometheus data source processor.
|
||||
func NewPrometheus(inp Input) *Prometheus {
|
||||
return &Prometheus{
|
||||
inp: inp,
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnection tests the connection to the Prometheus data source.
|
||||
func (p *Prometheus) TestConnection(ctx context.Context) (ConnectionStatus, error) {
|
||||
client, err := createPrometheusClient(p.inp.URL(), p.inp.Credentials())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error creating prometheus client: %w", err)
|
||||
}
|
||||
|
||||
res, err := client.Buildinfo(ctx)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "401") {
|
||||
return ConnectionStatusUnauthorized, nil
|
||||
}
|
||||
|
||||
return ConnectionStatusUnreachable, nil
|
||||
}
|
||||
|
||||
// TODO: Add a supported list of versions.
|
||||
if res.Version == "" {
|
||||
return ConnectionStatusVersionNotSupported, nil
|
||||
}
|
||||
|
||||
return ConnectionStatusSuccess, nil
|
||||
}
|
||||
|
||||
// Metadata retrieves metadata from the Prometheus data source.
|
||||
func (p *Prometheus) Metadata(ctx context.Context) (*PrometheusMetadataResult, error) {
|
||||
client, err := createPrometheusClient(p.inp.URL(), p.inp.Credentials())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating prometheus client: %w", err)
|
||||
}
|
||||
|
||||
v, err := client.Metadata(ctx, "", _metadataLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching metadata: %w", err)
|
||||
}
|
||||
|
||||
return &PrometheusMetadataResult{
|
||||
Result: v,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryRange performs a query against the Prometheus data source.
|
||||
func (p *Prometheus) QueryRange(ctx context.Context, q string, tr TimeRange) (*PrometheusQueryResult, error) {
|
||||
client, err := createPrometheusClient(p.inp.URL(), p.inp.Credentials())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating prometheus client: %w", err)
|
||||
}
|
||||
|
||||
ntr := tr.Normalize()
|
||||
|
||||
v, warns, err := client.QueryRange(ctx, ntr.ProcessPrometheusQuery(q), v1.Range{
|
||||
Start: ntr.From,
|
||||
End: ntr.To,
|
||||
Step: ntr.QueryStep(),
|
||||
}, v1.WithLimit(_queryLimit))
|
||||
if err != nil {
|
||||
var perr *v1.Error
|
||||
|
||||
if ok := errors.As(err, &perr); ok {
|
||||
return nil, NewInvalidQueryError(perr.Msg)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("error performing query: %w", err)
|
||||
}
|
||||
|
||||
return &PrometheusQueryResult{
|
||||
Type: v.Type(),
|
||||
Result: v,
|
||||
Warnings: warns,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LabelNames retrieves all label names from the Prometheus data source.
|
||||
// The matchers parameter can be used to filter label names by series selectors.
|
||||
func (p *Prometheus) LabelNames(ctx context.Context, matchers []string, tr TimeRange) (*PrometheusLabelNamesResult, error) {
|
||||
client, err := createPrometheusClient(p.inp.URL(), p.inp.Credentials())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating prometheus client: %w", err)
|
||||
}
|
||||
|
||||
ntr := tr.Normalize()
|
||||
|
||||
names, warns, err := client.LabelNames(
|
||||
ctx,
|
||||
matchers,
|
||||
ntr.From,
|
||||
ntr.To,
|
||||
v1.WithLimit(_labelLimit),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching label names: %w", err)
|
||||
}
|
||||
|
||||
return &PrometheusLabelNamesResult{
|
||||
Warnings: warns,
|
||||
Result: names,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LabelValues retrieves all values for a specific label from the Prometheus data source.
|
||||
// The matchers parameter can be used to filter label values by series selectors.
|
||||
func (p *Prometheus) LabelValues(ctx context.Context, label string, matchers []string, tr TimeRange) (*PrometheusLabelValuesResult, error) {
|
||||
client, err := createPrometheusClient(p.inp.URL(), p.inp.Credentials())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating prometheus client: %w", err)
|
||||
}
|
||||
|
||||
ntr := tr.Normalize()
|
||||
|
||||
values, warns, err := client.LabelValues(
|
||||
ctx,
|
||||
label,
|
||||
matchers,
|
||||
ntr.From,
|
||||
ntr.To,
|
||||
v1.WithLimit(_labelLimit),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching label values: %w", err)
|
||||
}
|
||||
|
||||
result := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
result[i] = string(v)
|
||||
}
|
||||
|
||||
return &PrometheusLabelValuesResult{
|
||||
Warnings: warns,
|
||||
Result: result,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Series retrieves series from the Prometheus data source matching the given selectors.
|
||||
// The matchers parameter specifies series selectors to filter the results.
|
||||
func (p *Prometheus) Series(ctx context.Context, matchers []string, tr TimeRange) (*PrometheusSeriesResult, error) {
|
||||
client, err := createPrometheusClient(p.inp.URL(), p.inp.Credentials())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating prometheus client: %w", err)
|
||||
}
|
||||
|
||||
ntr := tr.Normalize()
|
||||
|
||||
series, warns, err := client.Series(
|
||||
ctx,
|
||||
matchers,
|
||||
ntr.From,
|
||||
ntr.To,
|
||||
v1.WithLimit(_labelLimit),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching series: %w", err)
|
||||
}
|
||||
|
||||
return &PrometheusSeriesResult{
|
||||
Warnings: warns,
|
||||
Result: series,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdatePrometheusCredentials updates the credentials for the Prometheus data source.
|
||||
func UpdatePrometheusCredentials(rawCreds Credentials, inp CredentialsUpdateInput) (Credentials, error) {
|
||||
var creds PrometheusCredentials
|
||||
|
||||
if rawCreds != nil {
|
||||
if err := json.Unmarshal(rawCreds, &creds); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling credentials: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var update PrometheusCredentialsUpdate
|
||||
|
||||
if err := json.Unmarshal(inp, &update); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling credentials update input: %w", err)
|
||||
}
|
||||
|
||||
if update.Username.Valid {
|
||||
creds.Username = update.Username.String
|
||||
}
|
||||
|
||||
if update.Password.Valid {
|
||||
creds.Password = update.Password.String
|
||||
}
|
||||
|
||||
if creds.Username == "" && creds.Password == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshaling updated credentials: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// createPrometheusClient creates a Prometheus API client using the provided URL and credentials.
|
||||
func createPrometheusClient(url string, creds Credentials) (v1.API, error) {
|
||||
var roundTripper http.RoundTripper
|
||||
|
||||
if creds != nil {
|
||||
var promCreds PrometheusCredentials
|
||||
|
||||
if err := json.Unmarshal(creds, &promCreds); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling credentials: %w", err)
|
||||
}
|
||||
|
||||
roundTripper = config.NewBasicAuthRoundTripper(
|
||||
config.NewInlineSecret(promCreds.Username),
|
||||
config.NewInlineSecret(promCreds.Password),
|
||||
api.DefaultRoundTripper,
|
||||
)
|
||||
}
|
||||
|
||||
client, err := api.NewClient(api.Config{
|
||||
Address: url,
|
||||
RoundTripper: roundTripper,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating prometheus client: %w", err)
|
||||
}
|
||||
|
||||
return v1.NewAPI(client), nil
|
||||
}
|
||||
|
||||
// PrometheusCredentials represents the credentials for a Prometheus data source.
|
||||
type PrometheusCredentials struct {
|
||||
// Username is the username for the Prometheus data source.
|
||||
Username string `json:"username"`
|
||||
|
||||
// Password is the password for the Prometheus data source.
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// PrometheusCredentialsUpdate represents the input for updating Prometheus credentials.
|
||||
type PrometheusCredentialsUpdate struct {
|
||||
// Username is the username for the Prometheus data source.
|
||||
Username null.String `json:"username"`
|
||||
|
||||
// Password is the password for the Prometheus data source.
|
||||
Password null.String `json:"password"`
|
||||
}
|
||||
|
||||
// PrometheusMetadataResult represents the result of a metadata query.
|
||||
type PrometheusMetadataResult struct {
|
||||
// Result contains the query result data.
|
||||
Result any `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// PrometheusQueryResult represents the result of a data source query.
|
||||
type PrometheusQueryResult struct {
|
||||
// Warnings contains any warnings generated during the query.
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
|
||||
// Type indicates the type of the query result.
|
||||
Type model.ValueType `json:"type"`
|
||||
|
||||
// Result contains the query result data.
|
||||
Result any `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// Transform transforms a PrometheusQueryResult into a unified QueryResult
|
||||
// based on the requested chart type.
|
||||
func (pqr *PrometheusQueryResult) Transform(ct ChartType) *QueryResult {
|
||||
if ct == "" {
|
||||
return &QueryResult{Status: QueryStatusTypeNotSelected}
|
||||
}
|
||||
|
||||
switch pqr.Type {
|
||||
case model.ValString:
|
||||
return &QueryResult{Status: QueryStatusChartAndDataMismatch}
|
||||
case model.ValScalar:
|
||||
return pqr.transformScalar(ct)
|
||||
case model.ValVector:
|
||||
return pqr.transformVector()
|
||||
case model.ValMatrix:
|
||||
return pqr.transformMatrix(ct)
|
||||
default:
|
||||
return &QueryResult{Status: QueryStatusNoData}
|
||||
}
|
||||
}
|
||||
|
||||
// transformScalar transforms a scalar Prometheus result.
|
||||
func (pqr *PrometheusQueryResult) transformScalar(ct ChartType) *QueryResult {
|
||||
if ct == ChartTypeLine {
|
||||
return &QueryResult{Status: QueryStatusChartAndDataMismatch}
|
||||
}
|
||||
|
||||
rawJSON, err := json.Marshal(pqr.Result)
|
||||
if err != nil {
|
||||
return &QueryResult{Status: QueryStatusInvalid}
|
||||
}
|
||||
|
||||
var scalar model.Scalar
|
||||
if err := json.Unmarshal(rawJSON, &scalar); err != nil {
|
||||
return &QueryResult{Status: QueryStatusInvalid}
|
||||
}
|
||||
|
||||
v := float64(scalar.Value)
|
||||
if !isValidValue(v) {
|
||||
return &QueryResult{Status: QueryStatusInvalid}
|
||||
}
|
||||
|
||||
return &QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: []QueryResultSeries{
|
||||
{
|
||||
Labels: map[string]string{},
|
||||
Metrics: [][2]any{
|
||||
{
|
||||
scalar.Timestamp.Unix(),
|
||||
v,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// transformVector transforms a vector Prometheus result.
|
||||
func (pqr *PrometheusQueryResult) transformVector() *QueryResult {
|
||||
rawJSON, err := json.Marshal(pqr.Result)
|
||||
if err != nil {
|
||||
return &QueryResult{Status: QueryStatusInvalid}
|
||||
}
|
||||
|
||||
var vector model.Vector
|
||||
if err := json.Unmarshal(rawJSON, &vector); err != nil {
|
||||
return &QueryResult{Status: QueryStatusInvalid}
|
||||
}
|
||||
|
||||
if len(vector) == 0 {
|
||||
return &QueryResult{Status: QueryStatusNoData}
|
||||
}
|
||||
|
||||
// Check if all samples are histograms.
|
||||
allHistograms := true
|
||||
for _, sample := range vector {
|
||||
if sample.Histogram == nil {
|
||||
allHistograms = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if allHistograms {
|
||||
return &QueryResult{Status: QueryStatusChartAndDataMismatch}
|
||||
}
|
||||
|
||||
var (
|
||||
series []QueryResultSeries
|
||||
hasValidValue bool
|
||||
)
|
||||
|
||||
for _, sample := range vector {
|
||||
if sample.Histogram != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
v := float64(sample.Value)
|
||||
if !isValidValue(v) {
|
||||
continue
|
||||
}
|
||||
|
||||
hasValidValue = true
|
||||
series = append(series, QueryResultSeries{
|
||||
Labels: promConvertPromMetricToLabels(sample.Metric),
|
||||
Metrics: [][2]any{
|
||||
{
|
||||
sample.Timestamp.Unix(),
|
||||
v,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if !hasValidValue {
|
||||
return &QueryResult{Status: QueryStatusInvalid}
|
||||
}
|
||||
|
||||
return &QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: series,
|
||||
}
|
||||
}
|
||||
|
||||
// transformMatrix transforms a matrix Prometheus result.
|
||||
func (pqr *PrometheusQueryResult) transformMatrix(ct ChartType) *QueryResult {
|
||||
rawJSON, err := json.Marshal(pqr.Result)
|
||||
if err != nil {
|
||||
return &QueryResult{Status: QueryStatusInvalid}
|
||||
}
|
||||
|
||||
var matrix model.Matrix
|
||||
if err := json.Unmarshal(rawJSON, &matrix); err != nil {
|
||||
return &QueryResult{Status: QueryStatusInvalid}
|
||||
}
|
||||
|
||||
if len(matrix) == 0 {
|
||||
return &QueryResult{Status: QueryStatusNoData}
|
||||
}
|
||||
|
||||
// Check if all streams are histogram-only.
|
||||
allHistograms := true
|
||||
for _, stream := range matrix {
|
||||
if len(stream.Values) > 0 {
|
||||
allHistograms = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if allHistograms {
|
||||
return &QueryResult{Status: QueryStatusChartAndDataMismatch}
|
||||
}
|
||||
|
||||
var (
|
||||
series []QueryResultSeries
|
||||
hasValidValue bool
|
||||
)
|
||||
|
||||
for _, stream := range matrix {
|
||||
if len(stream.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var metrics [][2]any
|
||||
|
||||
if ct == ChartTypeGauge {
|
||||
// For gauge, only keep the last valid value.
|
||||
for i := len(stream.Values) - 1; i >= 0; i-- {
|
||||
v := float64(stream.Values[i].Value)
|
||||
if isValidValue(v) {
|
||||
hasValidValue = true
|
||||
metrics = [][2]any{
|
||||
{
|
||||
stream.Values[i].Timestamp.Unix(),
|
||||
v,
|
||||
},
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, sp := range stream.Values {
|
||||
v := float64(sp.Value)
|
||||
if !isValidValue(v) {
|
||||
continue
|
||||
}
|
||||
|
||||
hasValidValue = true
|
||||
metrics = append(metrics, [2]any{
|
||||
sp.Timestamp.Unix(),
|
||||
v,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(metrics) > 0 {
|
||||
series = append(series, QueryResultSeries{
|
||||
Labels: promConvertPromMetricToLabels(stream.Metric),
|
||||
Metrics: metrics,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if !hasValidValue {
|
||||
return &QueryResult{Status: QueryStatusInvalid}
|
||||
}
|
||||
|
||||
return &QueryResult{
|
||||
Status: QueryStatusOK,
|
||||
Data: series,
|
||||
}
|
||||
}
|
||||
|
||||
// promConvertPromMetricToLabels converts a Prometheus Metric (which is a set of label
|
||||
// key-value pairs) into a map[string]string for easier use in our QueryResultSeries.
|
||||
func promConvertPromMetricToLabels(m model.Metric) map[string]string {
|
||||
res := make(map[string]string, len(m))
|
||||
|
||||
for k, v := range m {
|
||||
res[string(k)] = string(v)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// PrometheusLabelNamesResult represents the result of a label names query.
|
||||
type PrometheusLabelNamesResult struct {
|
||||
// Warnings contains any warnings generated during the query.
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
|
||||
// Result contains the label names.
|
||||
Result []string `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// PrometheusLabelValuesResult represents the result of a label values query.
|
||||
type PrometheusLabelValuesResult struct {
|
||||
// Warnings contains any warnings generated during the query.
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
|
||||
// Result contains the label values.
|
||||
Result []string `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// PrometheusSeriesResult represents the result of a series query.
|
||||
type PrometheusSeriesResult struct {
|
||||
// Warnings contains any warnings generated during the query.
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
|
||||
// Result contains the matching series label sets.
|
||||
Result []model.LabelSet `json:"result,omitempty"`
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProcessPrometheusQuery processes the query string to replace Prometheus-specific
|
||||
// macros, then applies generic macro expansion via ProcessQuery.
|
||||
//
|
||||
// Prometheus-specific macros:
|
||||
//
|
||||
// $__rate_interval → 4x interval or 1m minimum, for rate()/increase() (e.g., "1h")
|
||||
// $__range → total time range duration (e.g., "1d")
|
||||
//
|
||||
// Generic macros (via ProcessQuery):
|
||||
//
|
||||
// $__from → unix milliseconds of the start time
|
||||
// $__to → unix milliseconds of the end time
|
||||
// $__interval → calculated step interval (e.g., "15m")
|
||||
func (tr TimeRange) ProcessPrometheusQuery(q string) string {
|
||||
// Resolve generic macros first ($__interval, $__from, $__to).
|
||||
q = tr.ProcessQuery(q)
|
||||
|
||||
interval := tr.calculateInterval()
|
||||
rateInterval := tr.calculateRateInterval(interval)
|
||||
rangeDuration := tr.To.Sub(tr.From)
|
||||
|
||||
q = strings.ReplaceAll(q, "$__rate_interval", formatInterval(rateInterval))
|
||||
q = strings.ReplaceAll(q, "$__range", formatInterval(rangeDuration))
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
// calculateRateInterval calculates the rate interval for use with rate() and similar
|
||||
// functions. This ensures there are enough data points for accurate rate calculations.
|
||||
// The rate interval is typically 4x the scrape interval or the calculated interval,
|
||||
// whichever is larger.
|
||||
func (tr TimeRange) calculateRateInterval(interval time.Duration) time.Duration {
|
||||
minRateInterval := time.Minute
|
||||
rateInterval := interval * 4
|
||||
|
||||
if rateInterval < minRateInterval {
|
||||
return minRateInterval
|
||||
}
|
||||
|
||||
return roundInterval(rateInterval)
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_TimeRange_ProcessPrometheusQuery(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
TimeRange TimeRange
|
||||
Query string
|
||||
Result string
|
||||
}{
|
||||
"Replaces $__interval": {
|
||||
// 1 day = 86400s / 100 = 864s -> rounds to 15m
|
||||
TimeRange: TimeRange{
|
||||
From: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Query: "rate(http_requests_total[$__interval])",
|
||||
Result: "rate(http_requests_total[15m])",
|
||||
},
|
||||
"Replaces $__rate_interval": {
|
||||
// 1 day interval=15m, rate_interval=15m*4=60m -> rounds to 1h
|
||||
TimeRange: TimeRange{
|
||||
From: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Query: "rate(http_requests_total[$__rate_interval])",
|
||||
Result: "rate(http_requests_total[1h])",
|
||||
},
|
||||
"Replaces both intervals": {
|
||||
TimeRange: TimeRange{
|
||||
From: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Query: "rate(a[$__interval]) + rate(b[$__rate_interval])",
|
||||
Result: "rate(a[15m]) + rate(b[1h])",
|
||||
},
|
||||
"Short range uses minimum rate interval": {
|
||||
// Short range: interval=15s (minimum), rate_interval=1m (minimum)
|
||||
TimeRange: TimeRange{
|
||||
From: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 1, 12, 5, 0, 0, time.UTC),
|
||||
},
|
||||
Query: "rate(x[$__rate_interval])",
|
||||
Result: "rate(x[1m])",
|
||||
},
|
||||
"Replaces short $__interval": {
|
||||
TimeRange: TimeRange{
|
||||
From: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 1, 0, 5, 0, 0, time.UTC),
|
||||
},
|
||||
Query: "rate(http_requests_total[$__interval])",
|
||||
Result: "rate(http_requests_total[15s])",
|
||||
},
|
||||
"Replaces $__range": {
|
||||
TimeRange: TimeRange{
|
||||
From: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Query: "changes(metric[$__range])",
|
||||
Result: "changes(metric[1d])",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := tc.TimeRange.ProcessPrometheusQuery(tc.Query)
|
||||
assert.Equal(t, tc.Result, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
80
server/core/internal/datasource/processor/query.go
Normal file
80
server/core/internal/datasource/processor/query.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
|
||||
"github.com/oxynote/purse/util/errutil"
|
||||
)
|
||||
|
||||
// ChartType represents the type of chart visualization.
|
||||
type ChartType string
|
||||
|
||||
const (
|
||||
// ChartTypeLine represents a line chart visualization.
|
||||
ChartTypeLine ChartType = "line_chart"
|
||||
|
||||
// ChartTypeBar represents a bar chart visualization.
|
||||
ChartTypeBar ChartType = "bar_chart"
|
||||
|
||||
// ChartTypeGauge represents a gauge chart visualization.
|
||||
ChartTypeGauge ChartType = "gauge_chart"
|
||||
)
|
||||
|
||||
// IsValid checks if the chart type is valid.
|
||||
func (ct ChartType) IsValid() bool {
|
||||
switch ct {
|
||||
case ChartTypeLine, ChartTypeBar, ChartTypeGauge:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// QueryStatus represents the status of a query result.
|
||||
type QueryStatus string
|
||||
|
||||
const (
|
||||
// QueryStatusOK indicates a successful query with valid data.
|
||||
QueryStatusOK QueryStatus = "ok"
|
||||
|
||||
// QueryStatusNoData indicates the query returned no data.
|
||||
QueryStatusNoData QueryStatus = "no-data"
|
||||
|
||||
// QueryStatusTypeNotSelected indicates no chart type was specified.
|
||||
QueryStatusTypeNotSelected QueryStatus = "type-not-selected"
|
||||
|
||||
// QueryStatusChartAndDataMismatch indicates the data cannot be rendered as the requested chart type.
|
||||
QueryStatusChartAndDataMismatch QueryStatus = "chart-and-data-mismatch"
|
||||
|
||||
// QueryStatusInvalid indicates the query result contains only invalid numeric values.
|
||||
QueryStatusInvalid QueryStatus = "invalid"
|
||||
)
|
||||
|
||||
// QueryResultSeries represents a single series in the query result.
|
||||
type QueryResultSeries struct {
|
||||
// Labels contains the labels of the associated metrics.
|
||||
Labels map[string]string `json:"labels"`
|
||||
|
||||
// Metrics contains [unix_timestamp_seconds, value] pairs.
|
||||
Metrics [][2]any `json:"metrics"`
|
||||
}
|
||||
|
||||
// QueryResult represents the unified response format for data source queries.
|
||||
type QueryResult struct {
|
||||
// Status indicates the result status.
|
||||
Status QueryStatus `json:"status"`
|
||||
|
||||
// Data contains the series data.
|
||||
Data []QueryResultSeries `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// isValidValue returns true if the float64 value is a valid numeric value (not NaN or Inf).
|
||||
func isValidValue(v float64) bool {
|
||||
return !math.IsNaN(v) && !math.IsInf(v, 0)
|
||||
}
|
||||
|
||||
// NewInvalidQueryError creates a new error indicating an invalid query with the provided message.
|
||||
func NewInvalidQueryError(msg string) error {
|
||||
return errutil.New(http.StatusBadRequest, "query.error", "%s", msg)
|
||||
}
|
||||
22
server/core/internal/datasource/processor/sql.go
Normal file
22
server/core/internal/datasource/processor/sql.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package processor
|
||||
|
||||
// SQLColumn represents a column in a SQL table.
|
||||
type SQLColumn struct {
|
||||
// Name is the name of the column.
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// SQLTable represents a table with its columns in a SQL database.
|
||||
type SQLTable struct {
|
||||
// Columns contains the list of columns in the table.
|
||||
Columns []SQLColumn `json:"columns"`
|
||||
}
|
||||
|
||||
// SQLMetadataResult represents the metadata of a SQL database.
|
||||
type SQLMetadataResult struct {
|
||||
// Tables maps schema-qualified table names to their column definitions.
|
||||
Tables map[string]SQLTable `json:"tables"`
|
||||
|
||||
// DefaultSchema is the default schema for the database (e.g., "public" for PostgreSQL).
|
||||
DefaultSchema string `json:"defaultSchema"`
|
||||
}
|
||||
237
server/core/internal/datasource/processor/time_range.go
Normal file
237
server/core/internal/datasource/processor/time_range.go
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oxynote/purse/util/errutil"
|
||||
"github.com/oxynote/purse/util/timeutil"
|
||||
)
|
||||
|
||||
const (
|
||||
// _maxDataPoints defines the default maximum number of data points for interval calculations.
|
||||
_maxDataPoints = 100
|
||||
|
||||
// _minQueryStep defines the default step duration for range queries.
|
||||
_minQueryStep = time.Second * 15
|
||||
)
|
||||
|
||||
// TimeRange represents a time range with a start and end time.
|
||||
type TimeRange struct {
|
||||
// From is the start time of the range.
|
||||
From time.Time `json:"from"`
|
||||
|
||||
// To is the end time of the range.
|
||||
To time.Time `json:"to"`
|
||||
}
|
||||
|
||||
// ParseTimeRange parses a TimeRange from URL values.
|
||||
func ParseTimeRange(
|
||||
fromOptional bool,
|
||||
v url.Values,
|
||||
) (*TimeRange, error) {
|
||||
from, err := time.Parse(time.RFC3339, v.Get("from"))
|
||||
if err != nil {
|
||||
if !fromOptional || v.Get("from") != "" {
|
||||
return nil, errutil.New(http.StatusBadRequest, "from.invalid", "From parameter must be a valid RFC3339 timestamp.")
|
||||
}
|
||||
|
||||
from = time.Time{}
|
||||
}
|
||||
|
||||
to := timeutil.Now()
|
||||
rawTo := v.Get("to")
|
||||
|
||||
if rawTo != "" {
|
||||
to, err = time.Parse(time.RFC3339, rawTo)
|
||||
if err != nil {
|
||||
return nil, errutil.New(http.StatusBadRequest, "to.invalid", "To parameter must be a valid RFC3339 timestamp.")
|
||||
}
|
||||
}
|
||||
|
||||
return &TimeRange{
|
||||
From: from,
|
||||
To: to,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryStep calculates the step duration for range queries based on the time range.
|
||||
func (tr TimeRange) QueryStep() time.Duration {
|
||||
return max(tr.calculateInterval(), _minQueryStep)
|
||||
}
|
||||
|
||||
// Normalize adjusts the From and To times to align with the specified step duration.
|
||||
func (tr TimeRange) Normalize() TimeRange {
|
||||
step := tr.QueryStep()
|
||||
|
||||
from := tr.From
|
||||
to := tr.To
|
||||
|
||||
stepSeconds := int64(step.Seconds())
|
||||
|
||||
if !from.IsZero() {
|
||||
from = time.Unix((from.Unix()/stepSeconds)*stepSeconds, 0).UTC()
|
||||
}
|
||||
|
||||
if !to.IsZero() {
|
||||
to = time.Unix(((to.Unix() + stepSeconds - 1) / stepSeconds * stepSeconds), 0).UTC()
|
||||
}
|
||||
|
||||
return TimeRange{
|
||||
From: from,
|
||||
To: to,
|
||||
}
|
||||
}
|
||||
|
||||
// calculateInterval calculates the appropriate step interval based on the time range
|
||||
// and maximum number of data points.
|
||||
func (tr TimeRange) calculateInterval() time.Duration {
|
||||
duration := tr.To.Sub(tr.From)
|
||||
if duration <= 0 {
|
||||
return 15 * time.Second
|
||||
}
|
||||
|
||||
interval := duration / time.Duration(_maxDataPoints)
|
||||
|
||||
return roundInterval(interval)
|
||||
}
|
||||
|
||||
// _timeVarRe matches ${__from} and ${__to} with optional :date modifiers.
|
||||
//
|
||||
// Examples: ${__from}, ${__from:date}, ${__from:date:iso}, ${__from:date:YYYY-MM}
|
||||
var _timeVarRe = regexp.MustCompile(`\$\{__(from|to)(?::(date)(?::([^}]+))?)?\}`)
|
||||
|
||||
// _isoMillisFormat is ISO 8601/RFC 3339 with millisecond precision.
|
||||
const _isoMillisFormat = "2006-01-02T15:04:05.000Z07:00"
|
||||
|
||||
// _timeFormatReplacer converts date format tokens (YYYY, MM, DD, etc.) to Go time layout tokens.
|
||||
// Longer tokens must appear before shorter ones to ensure correct matching.
|
||||
var _timeFormatReplacer = strings.NewReplacer(
|
||||
"YYYY", "2006",
|
||||
"YY", "06",
|
||||
"MMMM", "January",
|
||||
"MMM", "Jan",
|
||||
"MM", "01",
|
||||
"M", "1",
|
||||
"DD", "02",
|
||||
"D", "2",
|
||||
"dddd", "Monday",
|
||||
"ddd", "Mon",
|
||||
"HH", "15",
|
||||
"hh", "03",
|
||||
"h", "3",
|
||||
"mm", "04",
|
||||
"m", "4",
|
||||
"ss", "05",
|
||||
"s", "5",
|
||||
"SSS", ".000",
|
||||
"A", "PM",
|
||||
"a", "pm",
|
||||
"ZZ", "-0700",
|
||||
)
|
||||
|
||||
// ProcessQuery processes the query string to replace generic macros
|
||||
// that are shared across all data source types.
|
||||
//
|
||||
// Supported macros:
|
||||
//
|
||||
// $__from → unix milliseconds of the start time
|
||||
// $__to → unix milliseconds of the end time
|
||||
// $__interval → calculated step interval (e.g., "15s", "5m", "1h")
|
||||
// ${__from} → unix milliseconds of the start time
|
||||
// ${__to} → unix milliseconds of the end time
|
||||
// ${__from:date} → ISO 8601 with milliseconds (e.g., "2020-07-13T20:19:09.254Z")
|
||||
// ${__from:date:iso} → ISO 8601 with milliseconds
|
||||
// ${__from:date:seconds} → unix seconds
|
||||
// ${__from:date:YYYY-MM} → custom date format
|
||||
//
|
||||
// Source-specific processors should call this after their own macro expansion
|
||||
// to avoid conflicts (e.g., Prometheus $__rate_interval contains $__interval as a substring).
|
||||
func (tr TimeRange) ProcessQuery(q string) string {
|
||||
// Replace ${__from...} and ${__to...} with modifiers first.
|
||||
q = _timeVarRe.ReplaceAllStringFunc(q, func(match string) string {
|
||||
submatch := _timeVarRe.FindStringSubmatch(match)
|
||||
|
||||
var t time.Time
|
||||
if submatch[1] == "from" {
|
||||
t = tr.From.UTC()
|
||||
} else {
|
||||
t = tr.To.UTC()
|
||||
}
|
||||
|
||||
// No :date modifier → unix milliseconds.
|
||||
if submatch[2] == "" {
|
||||
return strconv.FormatInt(t.UnixMilli(), 10)
|
||||
}
|
||||
|
||||
// :date with optional format specifier.
|
||||
switch submatch[3] {
|
||||
case "", "iso":
|
||||
return t.Format(_isoMillisFormat)
|
||||
case "seconds":
|
||||
return strconv.FormatInt(t.Unix(), 10)
|
||||
default:
|
||||
return t.Format(toGoTimeFormat(submatch[3]))
|
||||
}
|
||||
})
|
||||
|
||||
// Bare $__from/$__to (no braces) → unix milliseconds.
|
||||
q = strings.ReplaceAll(q, "$__from", strconv.FormatInt(tr.From.UnixMilli(), 10))
|
||||
q = strings.ReplaceAll(q, "$__to", strconv.FormatInt(tr.To.UnixMilli(), 10))
|
||||
q = strings.ReplaceAll(q, "$__interval", formatInterval(tr.calculateInterval()))
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
// toGoTimeFormat converts a date format string (e.g., "YYYY-MM-DD") to a Go time layout string.
|
||||
func toGoTimeFormat(f string) string {
|
||||
return _timeFormatReplacer.Replace(f)
|
||||
}
|
||||
|
||||
// formatInterval formats a duration as an interval string.
|
||||
func formatInterval(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
|
||||
// roundInterval rounds the given duration to an interval value
|
||||
// that makes sense for Prometheus queries.
|
||||
func roundInterval(d time.Duration) time.Duration {
|
||||
intervals := []time.Duration{
|
||||
15 * time.Second,
|
||||
30 * time.Second,
|
||||
time.Minute,
|
||||
2 * time.Minute,
|
||||
5 * time.Minute,
|
||||
10 * time.Minute,
|
||||
15 * time.Minute,
|
||||
30 * time.Minute,
|
||||
time.Hour,
|
||||
2 * time.Hour,
|
||||
6 * time.Hour,
|
||||
12 * time.Hour,
|
||||
24 * time.Hour,
|
||||
}
|
||||
|
||||
for _, interval := range intervals {
|
||||
if d <= interval {
|
||||
return interval
|
||||
}
|
||||
}
|
||||
|
||||
return 24 * time.Hour
|
||||
}
|
||||
|
||||
229
server/core/internal/datasource/processor/time_range_test.go
Normal file
229
server/core/internal/datasource/processor/time_range_test.go
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_TimeRange_ProcessQuery(t *testing.T) {
|
||||
tr := TimeRange{
|
||||
From: time.Date(2024, 1, 15, 10, 30, 45, 254000000, time.UTC),
|
||||
To: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
tests := map[string]struct {
|
||||
TimeRange TimeRange
|
||||
Query string
|
||||
Result string
|
||||
}{
|
||||
// Bare $__from / $__to (no braces)
|
||||
"Bare $__from returns unix milliseconds": {
|
||||
TimeRange: tr,
|
||||
Query: "SELECT * FROM metrics WHERE ts >= $__from",
|
||||
Result: "SELECT * FROM metrics WHERE ts >= 1705314645254",
|
||||
},
|
||||
"Bare $__to returns unix milliseconds": {
|
||||
TimeRange: tr,
|
||||
Query: "SELECT * FROM metrics WHERE ts <= $__to",
|
||||
Result: "SELECT * FROM metrics WHERE ts <= 1705316400000",
|
||||
},
|
||||
|
||||
// ${__from} / ${__to} without modifier
|
||||
"${__from} returns unix milliseconds": {
|
||||
TimeRange: tr,
|
||||
Query: "SELECT * FROM metrics WHERE ts >= ${__from}",
|
||||
Result: "SELECT * FROM metrics WHERE ts >= 1705314645254",
|
||||
},
|
||||
"${__to} returns unix milliseconds": {
|
||||
TimeRange: tr,
|
||||
Query: "SELECT * FROM metrics WHERE ts <= ${__to}",
|
||||
Result: "SELECT * FROM metrics WHERE ts <= 1705316400000",
|
||||
},
|
||||
|
||||
// ${__from:date} / ${__from:date:iso}
|
||||
"${__from:date} returns ISO 8601 with milliseconds": {
|
||||
TimeRange: tr,
|
||||
Query: "SELECT * FROM metrics WHERE ts >= '${__from:date}'",
|
||||
Result: "SELECT * FROM metrics WHERE ts >= '2024-01-15T10:30:45.254Z'",
|
||||
},
|
||||
"${__from:date:iso} returns ISO 8601 with milliseconds": {
|
||||
TimeRange: tr,
|
||||
Query: "SELECT * FROM metrics WHERE ts >= '${__from:date:iso}'",
|
||||
Result: "SELECT * FROM metrics WHERE ts >= '2024-01-15T10:30:45.254Z'",
|
||||
},
|
||||
"${__to:date} returns ISO 8601 with milliseconds": {
|
||||
TimeRange: tr,
|
||||
Query: "SELECT * FROM metrics WHERE ts <= '${__to:date}'",
|
||||
Result: "SELECT * FROM metrics WHERE ts <= '2024-01-15T11:00:00.000Z'",
|
||||
},
|
||||
|
||||
// ${__from:date:seconds}
|
||||
"${__from:date:seconds} returns unix seconds": {
|
||||
TimeRange: tr,
|
||||
Query: "SELECT * FROM metrics WHERE ts >= ${__from:date:seconds}",
|
||||
Result: "SELECT * FROM metrics WHERE ts >= 1705314645",
|
||||
},
|
||||
"${__to:date:seconds} returns unix seconds": {
|
||||
TimeRange: tr,
|
||||
Query: "SELECT * FROM metrics WHERE ts <= ${__to:date:seconds}",
|
||||
Result: "SELECT * FROM metrics WHERE ts <= 1705316400",
|
||||
},
|
||||
|
||||
// ${__from:date:FORMAT}
|
||||
"${__from:date:YYYY-MM} returns custom format": {
|
||||
TimeRange: tr,
|
||||
Query: "SELECT * FROM metrics WHERE month = '${__from:date:YYYY-MM}'",
|
||||
Result: "SELECT * FROM metrics WHERE month = '2024-01'",
|
||||
},
|
||||
"${__from:date:YYYY-MM-DD} returns custom format": {
|
||||
TimeRange: tr,
|
||||
Query: "SELECT '${__from:date:YYYY-MM-DD}'",
|
||||
Result: "SELECT '2024-01-15'",
|
||||
},
|
||||
"${__from:date:YYYY-MM-DD HH} returns custom format with hours": {
|
||||
TimeRange: tr,
|
||||
Query: "SELECT '${__from:date:YYYY-MM-DD HH}'",
|
||||
Result: "SELECT '2024-01-15 10'",
|
||||
},
|
||||
|
||||
// $__interval
|
||||
"Replaces $__interval with calculated step": {
|
||||
// 1 day = 86400s / 100 = 864s -> rounds to 15m
|
||||
TimeRange: TimeRange{
|
||||
From: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Query: "rate(http_requests_total[$__interval])",
|
||||
Result: "rate(http_requests_total[15m])",
|
||||
},
|
||||
"Replaces $__interval with short step": {
|
||||
TimeRange: TimeRange{
|
||||
From: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 1, 0, 5, 0, 0, time.UTC),
|
||||
},
|
||||
Query: "rate(x[$__interval])",
|
||||
Result: "rate(x[15s])",
|
||||
},
|
||||
|
||||
// Mixed
|
||||
"Multiple modifiers in one query": {
|
||||
TimeRange: tr,
|
||||
Query: "SELECT * FROM metrics WHERE ts >= ${__from:date:seconds} AND ts <= ${__to:date:seconds} AND label = '${__from:date:YYYY-MM}'",
|
||||
Result: "SELECT * FROM metrics WHERE ts >= 1705314645 AND ts <= 1705316400 AND label = '2024-01'",
|
||||
},
|
||||
"No macros leaves query unchanged": {
|
||||
TimeRange: tr,
|
||||
Query: "SELECT * FROM metrics",
|
||||
Result: "SELECT * FROM metrics",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := tc.TimeRange.ProcessQuery(tc.Query)
|
||||
assert.Equal(t, tc.Result, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_toGoTimeFormat(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
Input string
|
||||
Result string
|
||||
}{
|
||||
"YYYY-MM": {Input: "YYYY-MM", Result: "2006-01"},
|
||||
"YYYY-MM-DD": {Input: "YYYY-MM-DD", Result: "2006-01-02"},
|
||||
"YYYY-MM-DD HH": {Input: "YYYY-MM-DD HH", Result: "2006-01-02 15"},
|
||||
"YY": {Input: "YY", Result: "06"},
|
||||
"M-D": {Input: "M-D", Result: "1-2"},
|
||||
"HH mm ss": {Input: "HH mm ss", Result: "15 04 05"},
|
||||
"hh A": {Input: "hh A", Result: "03 PM"},
|
||||
"SSS": {Input: "SSS", Result: ".000"},
|
||||
"YYYY-MM-DD ddd": {Input: "YYYY-MM-DD ddd", Result: "2006-01-02 Mon"},
|
||||
"YYYY-MM-DD dddd": {Input: "YYYY-MM-DD dddd", Result: "2006-01-02 Monday"},
|
||||
"MMMM": {Input: "MMMM", Result: "January"},
|
||||
"MMM": {Input: "MMM", Result: "Jan"},
|
||||
"ZZ": {Input: "YYYY-MM-DDZZ", Result: "2006-01-02-0700"},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := toGoTimeFormat(tc.Input)
|
||||
assert.Equal(t, tc.Result, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_TimeRange_Normalize(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
TimeRange TimeRange
|
||||
ExpectedStep time.Duration
|
||||
Result TimeRange
|
||||
}{
|
||||
"Short range uses minimum 15s step": {
|
||||
// ~5 min range -> calculated interval < 15s -> uses minimum 15s
|
||||
TimeRange: TimeRange{
|
||||
From: time.Date(2024, 1, 1, 12, 0, 4, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 1, 12, 5, 35, 0, time.UTC),
|
||||
},
|
||||
ExpectedStep: time.Second * 15,
|
||||
Result: TimeRange{
|
||||
From: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 1, 12, 5, 45, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
"1 hour range uses 1m step": {
|
||||
// 1 hour = 3600s / 100 = 36s -> rounds to 1m
|
||||
TimeRange: TimeRange{
|
||||
From: time.Date(2024, 1, 1, 12, 0, 4, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 1, 13, 0, 4, 0, time.UTC),
|
||||
},
|
||||
ExpectedStep: time.Minute,
|
||||
Result: TimeRange{
|
||||
From: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 1, 13, 1, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
"1 day range uses 15m step": {
|
||||
// 1 day = 86400s / 100 = 864s -> rounds to 15m
|
||||
TimeRange: TimeRange{
|
||||
From: time.Date(2024, 1, 1, 0, 0, 30, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 2, 0, 0, 30, 0, time.UTC),
|
||||
},
|
||||
ExpectedStep: time.Minute * 15,
|
||||
Result: TimeRange{
|
||||
From: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 2, 0, 15, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
"7 day range uses 2h step": {
|
||||
// 7 days = 604800s / 100 = 6048s -> rounds to 2h
|
||||
TimeRange: TimeRange{
|
||||
From: time.Date(2024, 1, 1, 0, 5, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 8, 0, 5, 0, 0, time.UTC),
|
||||
},
|
||||
ExpectedStep: time.Hour * 2,
|
||||
Result: TimeRange{
|
||||
From: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 8, 2, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, tc.ExpectedStep, tc.TimeRange.QueryStep())
|
||||
|
||||
res := tc.TimeRange.Normalize()
|
||||
assert.Equal(t, tc.Result, res)
|
||||
})
|
||||
}
|
||||
}
|
||||
196
server/core/internal/datasource/runner.go
Normal file
196
server/core/internal/datasource/runner.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/oxynote/heimdall/internal/datasource/processor"
|
||||
"github.com/oxynote/purse/util/errutil"
|
||||
)
|
||||
|
||||
// Runner represents the data source processor with necessary information for processing.
|
||||
type Runner struct {
|
||||
// Type indicates the type of the data source (e.g., Prometheus).
|
||||
Type Type `json:"type"`
|
||||
|
||||
// URL is the endpoint URL of the data source.
|
||||
URL string `json:"url"`
|
||||
|
||||
// Credentials holds the authentication credentials for the data source.
|
||||
Credentials processor.Credentials `json:"credentials"`
|
||||
|
||||
prepared bool
|
||||
runner runner
|
||||
}
|
||||
|
||||
// NewRunner creates a new Runner instance from the given DataSource.
|
||||
func NewRunner(ds DataSource) Runner {
|
||||
return Runner{
|
||||
Type: ds.Type,
|
||||
URL: ds.URL,
|
||||
Credentials: ds.Credentials,
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnection tests the connection to the data source.
|
||||
func (r *Runner) TestConnection(ctx context.Context) (processor.ConnectionStatus, error) {
|
||||
if err := r.ensurePrepared(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return r.runner.TestConnection(ctx)
|
||||
}
|
||||
|
||||
// Prometheus returns a prometheus client or an error in case data source is not of type prometheus.
|
||||
// The boolean return value indicates whether the data source status has changed.
|
||||
func (r *Runner) Prometheus(ctx context.Context) (Prometheus, processor.ConnectionStatus, error) {
|
||||
if r.Type != TypePrometheus {
|
||||
return nil, "", errutil.ErrNotFound
|
||||
}
|
||||
|
||||
if err := r.ensurePrepared(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
cs, err := r.runner.TestConnection(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return r.runner.(*processor.Prometheus), cs, nil
|
||||
}
|
||||
|
||||
// PostgreSQL returns a postgresql client or an error in case data source is not of type postgresql.
|
||||
// The boolean return value indicates whether the data source status has changed.
|
||||
func (r *Runner) PostgreSQL(ctx context.Context) (PostgreSQL, processor.ConnectionStatus, error) {
|
||||
if r.Type != TypePostgreSQL {
|
||||
return nil, "", errutil.ErrNotFound
|
||||
}
|
||||
|
||||
if err := r.ensurePrepared(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
cs, err := r.runner.TestConnection(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return r.runner.(*processor.PostgreSQL), cs, nil
|
||||
}
|
||||
|
||||
// MySQL returns a mysql client or an error in case data source is not of type mariadb or mysql.
|
||||
func (r *Runner) MySQL(ctx context.Context) (MySQL, processor.ConnectionStatus, error) {
|
||||
if r.Type != TypeMariaDB && r.Type != TypeMySQL {
|
||||
return nil, "", errutil.ErrNotFound
|
||||
}
|
||||
|
||||
if err := r.ensurePrepared(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
cs, err := r.runner.TestConnection(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return r.runner.(*processor.MySQL), cs, nil
|
||||
}
|
||||
|
||||
// SQL returns a SQL client or an error in case data source is not a SQL-based type.
|
||||
func (r *Runner) SQL(ctx context.Context) (SQL, processor.ConnectionStatus, error) {
|
||||
switch r.Type {
|
||||
case TypePostgreSQL:
|
||||
return r.PostgreSQL(ctx)
|
||||
case TypeMariaDB, TypeMySQL:
|
||||
return r.MySQL(ctx)
|
||||
default:
|
||||
return nil, "", errutil.ErrNotFound
|
||||
}
|
||||
}
|
||||
|
||||
// ensurePrepared prepares the hook for processing.
|
||||
func (r *Runner) ensurePrepared() error {
|
||||
if r.prepared {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch r.Type {
|
||||
case TypePrometheus:
|
||||
r.runner = processor.NewPrometheus(newStateInput(r.URL, r.Credentials))
|
||||
r.prepared = true
|
||||
|
||||
return nil
|
||||
case TypePostgreSQL:
|
||||
r.runner = processor.NewPostgreSQL(newStateInput(r.URL, r.Credentials))
|
||||
r.prepared = true
|
||||
|
||||
return nil
|
||||
case TypeMariaDB, TypeMySQL:
|
||||
r.runner = processor.NewMySQL(newStateInput(r.URL, r.Credentials))
|
||||
r.prepared = true
|
||||
|
||||
return nil
|
||||
default:
|
||||
return errors.New("invalid data source type")
|
||||
}
|
||||
}
|
||||
|
||||
// Prometheus represents a Prometheus data source processor.
|
||||
type Prometheus interface {
|
||||
runner
|
||||
|
||||
// Metadata retrieves metadata about the data source.
|
||||
Metadata(ctx context.Context) (*processor.PrometheusMetadataResult, error)
|
||||
|
||||
// QueryRange performs a query against the data source over a specified time range.
|
||||
QueryRange(ctx context.Context, q string, tr processor.TimeRange) (*processor.PrometheusQueryResult, error)
|
||||
|
||||
// LabelNames retrieves label names from the data source based on matchers and time range.
|
||||
LabelNames(ctx context.Context, matchers []string, tr processor.TimeRange) (*processor.PrometheusLabelNamesResult, error)
|
||||
|
||||
// LabelValues retrieves label values for a specific label from the data source based on matchers and time range.
|
||||
LabelValues(ctx context.Context, label string, matchers []string, tr processor.TimeRange) (*processor.PrometheusLabelValuesResult, error)
|
||||
|
||||
// Series retrieves series matching the given selectors from the data source based on matchers and time range.
|
||||
Series(ctx context.Context, matchers []string, tr processor.TimeRange) (*processor.PrometheusSeriesResult, error)
|
||||
}
|
||||
|
||||
// SQL represents a SQL-based data source processor.
|
||||
type SQL interface {
|
||||
runner
|
||||
|
||||
// Metadata retrieves all tables and their columns from the data source.
|
||||
Metadata(ctx context.Context) (*processor.SQLMetadataResult, error)
|
||||
|
||||
// QueryLabels executes a SQL query with LIMIT 1 and returns string column names with example values.
|
||||
QueryLabels(ctx context.Context, q string, tr processor.TimeRange) (map[string]string, error)
|
||||
}
|
||||
|
||||
// PostgreSQL represents a PostgreSQL data source processor.
|
||||
type PostgreSQL interface {
|
||||
SQL
|
||||
|
||||
// Query executes a SQL query against the data source.
|
||||
Query(ctx context.Context, q string, tr processor.TimeRange) (*processor.PostgreSQLQueryResult, error)
|
||||
|
||||
// QueryLabels executes a SQL query with LIMIT 1 and returns string column names with example values.
|
||||
QueryLabels(ctx context.Context, q string, tr processor.TimeRange) (map[string]string, error)
|
||||
}
|
||||
|
||||
// MySQL represents a MySQL data source processor.
|
||||
type MySQL interface {
|
||||
SQL
|
||||
|
||||
// Query executes a SQL query against the data source.
|
||||
Query(ctx context.Context, q string, tr processor.TimeRange) (*processor.MySQLQueryResult, error)
|
||||
|
||||
// QueryLabels executes a SQL query with LIMIT 1 and returns string column names with example values.
|
||||
QueryLabels(ctx context.Context, q string, tr processor.TimeRange) (map[string]string, error)
|
||||
}
|
||||
|
||||
// runner represents a data source runner.
|
||||
type runner interface {
|
||||
// TestConnection tests the data source connection.
|
||||
TestConnection(ctx context.Context) (processor.ConnectionStatus, error)
|
||||
}
|
||||
138
server/core/internal/db/data_source.go
Normal file
138
server/core/internal/db/data_source.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/oxynote/heimdall/internal/datasource"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// InsertDataSource inserts a data source into the database.
|
||||
func (a *agent) InsertDataSource(ctx context.Context, ds *datasource.DataSource) error {
|
||||
safeCredentials, err := ds.Credentials.Encrypt(a.opts.DataSourceCredentialsSigningSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q, args := a.builder.Insert("data_sources").
|
||||
SetMap(map[string]any{
|
||||
"id": ds.ID,
|
||||
"fk_organization_id": ds.OrganizationID,
|
||||
"name": ds.Name,
|
||||
"type": ds.Type,
|
||||
"url": ds.URL,
|
||||
"credentials": safeCredentials,
|
||||
"status": ds.Status,
|
||||
"created_at": ds.CreatedAt,
|
||||
"updated_at": ds.UpdatedAt,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
_, err = a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateDataSource updates an existing data source in the database.
|
||||
func (a *agent) UpdateDataSource(ctx context.Context, ds *datasource.DataSource) error {
|
||||
safeCredentials, err := ds.Credentials.Encrypt(a.opts.DataSourceCredentialsSigningSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q, args := a.builder.Update("data_sources").
|
||||
SetMap(map[string]any{
|
||||
"name": ds.Name,
|
||||
"url": ds.URL,
|
||||
"credentials": safeCredentials,
|
||||
"status": ds.Status,
|
||||
"updated_at": ds.UpdatedAt,
|
||||
}).
|
||||
Where(sq.Eq{
|
||||
"id": ds.ID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
_, err = a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteDataSource removes a data source from the database.
|
||||
func (a *agent) DeleteDataSource(ctx context.Context, id xid.ID, organizationID string) error {
|
||||
q, args := a.builder.Delete("data_sources").
|
||||
Where(sq.Eq{
|
||||
"id": id,
|
||||
"fk_organization_id": organizationID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchDataSource retrieves a data source by ID and organization ID.
|
||||
func (a *agent) FetchDataSource(ctx context.Context, id xid.ID, organizationID string) (*datasource.DataSource, error) {
|
||||
q, args := a.selectDataSource(a.builder.Select()).
|
||||
Where(sq.Eq{
|
||||
"id": id,
|
||||
"fk_organization_id": organizationID,
|
||||
}).
|
||||
Limit(1).
|
||||
MustSql()
|
||||
|
||||
ds := &datasource.DataSource{}
|
||||
if err := sqlx.GetContext(ctx, a.sql, ds, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err := ds.Credentials.Decrypt(a.opts.DataSourceCredentialsSigningSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ds, nil
|
||||
}
|
||||
|
||||
// FetchDataSources retrieves all data sources for an organization.
|
||||
func (a *agent) FetchDataSources(ctx context.Context, organizationID string) ([]datasource.DataSource, error) {
|
||||
q, args := a.selectDataSource(a.builder.Select()).
|
||||
Where(sq.Eq{
|
||||
"fk_organization_id": organizationID,
|
||||
}).
|
||||
OrderBy("created_at DESC").
|
||||
MustSql()
|
||||
|
||||
var sources []datasource.DataSource
|
||||
|
||||
if err := sqlx.SelectContext(ctx, a.sql, &sources, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range sources {
|
||||
err := sources[i].Credentials.Decrypt(a.opts.DataSourceCredentialsSigningSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
// selectDataSource prepares a SQL select statement for fetching data sources.
|
||||
func (a *agent) selectDataSource(b sq.SelectBuilder) sq.SelectBuilder {
|
||||
return b.Columns(
|
||||
"id",
|
||||
"fk_organization_id",
|
||||
"name",
|
||||
"type",
|
||||
"url",
|
||||
"credentials",
|
||||
"status",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
).From("data_sources")
|
||||
}
|
||||
257
server/core/internal/db/db.go
Normal file
257
server/core/internal/db/db.go
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
// Package db provides functionality to persistently store data of
|
||||
// the whole application system.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
pgx "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lann/builder"
|
||||
"github.com/oxynote/purse/util/errutil"
|
||||
"github.com/oxynote/purse/util/ioutil"
|
||||
"github.com/oxynote/purse/util/metricutil"
|
||||
"github.com/oxynote/purse/util/sqlutil"
|
||||
"github.com/oxynote/purse/util/timeutil"
|
||||
"github.com/qustavo/sqlhooks/v2"
|
||||
migr "github.com/rubenv/sql-migrate"
|
||||
)
|
||||
|
||||
// _migrations contains all database migrations.
|
||||
//
|
||||
//go:embed migrations
|
||||
var _migrations embed.FS
|
||||
|
||||
const (
|
||||
// _maxIdleConnections is the maximum number of idle connections to the database.
|
||||
_maxIdleConnections = 10
|
||||
|
||||
// _connMaxIdleTime is the maximum amount of time a connection may be idle.
|
||||
_connMaxIdleTime = 5 * time.Second
|
||||
|
||||
// _maxOpenConnections is the maximum number of open connections to the database.
|
||||
_maxOpenConnections = 100
|
||||
|
||||
// _connMaxLifetime is the maximum amount of time a connection may be reused.
|
||||
_connMaxLifetime = 2 * time.Minute
|
||||
)
|
||||
|
||||
type agent struct {
|
||||
sql sqlx.ExtContext
|
||||
builder sq.StatementBuilderType
|
||||
opts Options
|
||||
}
|
||||
|
||||
// DB contains dependencies needed for direct communication
|
||||
// with the database.
|
||||
type DB struct {
|
||||
agent
|
||||
|
||||
log *slog.Logger
|
||||
sql *sqlx.DB
|
||||
metrics metricutil.Factory
|
||||
migrations migr.MigrationSet
|
||||
|
||||
closer io.Closer
|
||||
}
|
||||
|
||||
// Options holds all database configuration options.
|
||||
type Options struct {
|
||||
// DSN specifies the location of the database.
|
||||
DSN string
|
||||
|
||||
// MaxNotifications specifies the maximum number of notifications
|
||||
// to keep per user.o
|
||||
// Zero means no limit.
|
||||
MaxNotifications uint64
|
||||
|
||||
// DataSourceCredentialsSigningSecret is the secret used to sign
|
||||
// data source credentials.
|
||||
DataSourceCredentialsSigningSecret string
|
||||
}
|
||||
|
||||
// New creates a fresh instance of database.
|
||||
func New(
|
||||
log *slog.Logger,
|
||||
metrics metricutil.Factory,
|
||||
opts Options,
|
||||
) (*DB, error) {
|
||||
// we use the unix nano timestamp as the driver name to ensure
|
||||
// that tests are able to register multiple drivers.
|
||||
driverName := strconv.Itoa(int(timeutil.Now().UnixNano()) + rand.Intn(1000)) //nolint:gosec // basic randomizer is enough for random driver name
|
||||
|
||||
drv := pgx.GetDefaultDriver()
|
||||
|
||||
sql.Register(driverName, sqlhooks.Wrap(
|
||||
drv,
|
||||
sqlutil.NewHooks(
|
||||
metrics,
|
||||
sqlutil.WithHookErrorHandler(DetectError),
|
||||
sqlutil.WithHookDatabaseName("heimdall"),
|
||||
sqlutil.WithHookDurationThreshold(time.Millisecond*100),
|
||||
)),
|
||||
)
|
||||
|
||||
sqlDB, err := sqlx.Connect(driverName, opts.DSN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// NOTE: although 'unsafe' sounds scary, it only affects
|
||||
// column parsing: when some columns are not found in
|
||||
// the destination struct, it won't return an error (the
|
||||
// default behaviour is to return one). This is useful
|
||||
// when parsing rows that contain a 'page_count' column.
|
||||
sqlDB = sqlDB.Unsafe()
|
||||
|
||||
sqlDB.SetMaxIdleConns(_maxIdleConnections)
|
||||
sqlDB.SetConnMaxIdleTime(_connMaxIdleTime)
|
||||
sqlDB.SetMaxOpenConns(_maxOpenConnections)
|
||||
sqlDB.SetConnMaxLifetime(_connMaxLifetime)
|
||||
|
||||
closers := []io.Closer{sqlDB}
|
||||
|
||||
db := &DB{
|
||||
log: log.With("component", "database"),
|
||||
sql: sqlDB,
|
||||
agent: agent{
|
||||
sql: sqlDB,
|
||||
builder: sq.StatementBuilderType(builder.EmptyBuilder).PlaceholderFormat(sq.Dollar),
|
||||
opts: opts,
|
||||
},
|
||||
metrics: metrics,
|
||||
migrations: migr.MigrationSet{
|
||||
TableName: "migrations",
|
||||
},
|
||||
}
|
||||
|
||||
if err = db.migrateUp(); err != nil {
|
||||
// NOCOV: error cannot be produced because the
|
||||
// database cannot be manipulated (eg. closed) from
|
||||
// the outside yet.
|
||||
return nil, ioutil.AppendCloseErr(
|
||||
ioutil.MultiCloser(true, closers...),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
db.closer = ioutil.MultiCloser(true, closers...)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// migrateUp prepares the database for usage.
|
||||
func (db *DB) migrateUp() error {
|
||||
fsys, err := fs.Sub(_migrations, "migrations")
|
||||
if err != nil {
|
||||
// NOCOV: since all arguments are constants, they
|
||||
// are always valid
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.migrations.Exec(
|
||||
db.sql.DB,
|
||||
"postgres",
|
||||
&migr.HttpFileSystemMigrationSource{
|
||||
FileSystem: http.FS(fsys),
|
||||
},
|
||||
migr.Up,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
func (db *DB) Close() error {
|
||||
return db.closer.Close()
|
||||
}
|
||||
|
||||
// DetectError converts internal, database-specific errors into user errors.
|
||||
// PostgreSQL error codes: https://www.postgresql.org/docs/11/errcodes-appendix.html.
|
||||
func DetectError(err error) error {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return errutil.ErrNotFound
|
||||
}
|
||||
|
||||
var perr *pgconn.PgError
|
||||
if !errors.As(err, &perr) {
|
||||
return err
|
||||
}
|
||||
|
||||
if perr.Code == "23505" { // Unique violation.
|
||||
switch perr.ConstraintName {
|
||||
case "data_sources_fk_organization_id_name_key":
|
||||
return errutil.New(
|
||||
http.StatusBadRequest,
|
||||
"data_source.duplicate_name",
|
||||
"name is already in use",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// BeginTx begins a transaction and tries to apply its object to the one
|
||||
// given in dest parameter.
|
||||
func (db *DB) BeginTx(ctx context.Context, dest any) error {
|
||||
value := reflect.ValueOf(dest)
|
||||
|
||||
if value.Kind() != reflect.Pointer || value.IsNil() {
|
||||
panic("dest is not a pointer")
|
||||
}
|
||||
|
||||
tx, err := db.sql.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if rerr := recover(); rerr != nil {
|
||||
tx.Rollback() //nolint:gosec,errcheck // rollback errors provide no meaningful info
|
||||
panic(rerr)
|
||||
}
|
||||
}()
|
||||
|
||||
value.Elem().Set(reflect.ValueOf(&Tx{
|
||||
agent: agent{
|
||||
sql: tx,
|
||||
builder: db.builder,
|
||||
opts: db.opts,
|
||||
},
|
||||
sql: tx,
|
||||
}))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tx contains dependencies needed for direct communication
|
||||
// with the database in a transaction mode.
|
||||
type Tx struct {
|
||||
agent
|
||||
|
||||
sql *sqlx.Tx
|
||||
}
|
||||
|
||||
// Commit commits a transaction.
|
||||
func (tx *Tx) Commit() error {
|
||||
return tx.sql.Commit()
|
||||
}
|
||||
|
||||
// Rollback rollbacks a transaction.
|
||||
func (tx *Tx) Rollback() error {
|
||||
return tx.sql.Rollback()
|
||||
}
|
||||
236
server/core/internal/db/db_test.go
Normal file
236
server/core/internal/db/db_test.go
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/dchest/uniuri"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/oxynote/purse/util/errutil"
|
||||
"github.com/oxynote/purse/util/ioutil"
|
||||
"github.com/oxynote/purse/util/metricutil"
|
||||
"github.com/orlangure/gnomock"
|
||||
pgDocker "github.com/orlangure/gnomock/preset/postgres"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
var _pgDSN string
|
||||
|
||||
const (
|
||||
_pgUser string = "pgtest"
|
||||
_pgPass string = "pgpass"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
container, err := gnomock.Start(pgDocker.Preset(
|
||||
pgDocker.WithUser(
|
||||
_pgUser,
|
||||
_pgPass,
|
||||
),
|
||||
))
|
||||
if err != nil {
|
||||
panic("cannot set up: " + err.Error())
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = gnomock.Stop(container)
|
||||
if err != nil {
|
||||
panic("cannot clean up: " + err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
_pgDSN = container.Host + ":" + strconv.Itoa(container.DefaultPort())
|
||||
|
||||
goleak.VerifyTestMain(m, goleak.IgnoreCurrent())
|
||||
}
|
||||
|
||||
func Test_New(t *testing.T) {
|
||||
// error - db file not found
|
||||
opts := Options{
|
||||
DSN: filepath.Join(t.TempDir(), "test", "123"),
|
||||
}
|
||||
|
||||
db, err := New(slog.New(slog.DiscardHandler), metricutil.NewFactory("test", nil), opts)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, db)
|
||||
|
||||
// success
|
||||
opts = Options{
|
||||
DSN: fmt.Sprintf("postgres://%s:%s@%s/postgres?sslmode=disable", _pgUser, _pgPass, _pgDSN),
|
||||
}
|
||||
|
||||
db, err = New(slog.New(slog.DiscardHandler), metricutil.NewFactory("test", nil), opts)
|
||||
assert.NoError(t, err)
|
||||
require.NotNil(t, db)
|
||||
assert.NotZero(t, db.log)
|
||||
assert.NotNil(t, db.agent)
|
||||
assert.NotNil(t, db.closer)
|
||||
assert.NotZero(t, db.builder)
|
||||
|
||||
assert.NoError(t, db.closer.Close())
|
||||
}
|
||||
|
||||
func Test_SystemDB_Close(t *testing.T) {
|
||||
db := &DB{
|
||||
closer: ioutil.CloserFunc(func() error {
|
||||
return assert.AnError
|
||||
}),
|
||||
}
|
||||
|
||||
assert.Error(t, db.Close())
|
||||
|
||||
db.closer = ioutil.CloserFunc(func() error {
|
||||
return nil
|
||||
})
|
||||
|
||||
assert.NoError(t, db.Close())
|
||||
}
|
||||
|
||||
func Test_DetectError(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Err error
|
||||
Result error
|
||||
}{
|
||||
"Not found": {
|
||||
Err: sql.ErrNoRows,
|
||||
Result: errutil.ErrNotFound,
|
||||
},
|
||||
"Error pass through": {
|
||||
Err: sql.ErrConnDone,
|
||||
Result: sql.ErrConnDone,
|
||||
},
|
||||
"No error": {
|
||||
Err: nil,
|
||||
Result: nil,
|
||||
},
|
||||
"Unmatched pgx constraint": {
|
||||
Err: &pgconn.PgError{
|
||||
Code: "123",
|
||||
},
|
||||
Result: &pgconn.PgError{
|
||||
Code: "123",
|
||||
},
|
||||
},
|
||||
"Unmatched constraint": {
|
||||
Err: errors.New("b"),
|
||||
Result: errors.New("b"),
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
assert.Equal(t, c.Result, DetectError(c.Err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_SystemDB_BeginTx(t *testing.T) {
|
||||
db := prepTempDB(t)
|
||||
|
||||
// provided destination isn't a pointer
|
||||
require.PanicsWithValue(
|
||||
t,
|
||||
"dest is not a pointer",
|
||||
func() {
|
||||
_ = db.BeginTx(context.Background(), struct{}{})
|
||||
},
|
||||
)
|
||||
|
||||
// provided destination is nil
|
||||
require.PanicsWithValue(
|
||||
t,
|
||||
"dest is not a pointer",
|
||||
func() {
|
||||
_ = db.BeginTx(context.Background(), (*struct{})(nil))
|
||||
},
|
||||
)
|
||||
|
||||
// cannot fulfill destination requirements
|
||||
require.Panics(
|
||||
t,
|
||||
func() {
|
||||
_ = db.BeginTx(context.Background(), &struct{}{})
|
||||
},
|
||||
)
|
||||
|
||||
type test2 interface {
|
||||
Commit() error
|
||||
}
|
||||
|
||||
var val test2
|
||||
|
||||
// context error
|
||||
nctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
require.Error(t, db.BeginTx(nctx, &val))
|
||||
|
||||
// success
|
||||
require.NoError(t, db.BeginTx(context.Background(), &val))
|
||||
assert.IsType(t, &Tx{}, val)
|
||||
assert.NoError(t, val.Commit())
|
||||
}
|
||||
|
||||
func Test_Tx_Commit(t *testing.T) {
|
||||
db := prepTempDB(t)
|
||||
|
||||
type test interface {
|
||||
Commit() error
|
||||
}
|
||||
|
||||
var ntx test
|
||||
|
||||
require.NoError(t, db.BeginTx(context.Background(), &ntx))
|
||||
require.NoError(t, ntx.Commit())
|
||||
require.Error(t, ntx.Commit())
|
||||
}
|
||||
|
||||
func Test_Tx_Rollback(t *testing.T) {
|
||||
db := prepTempDB(t)
|
||||
|
||||
type test interface {
|
||||
Rollback() error
|
||||
}
|
||||
|
||||
var ntx test
|
||||
|
||||
require.NoError(t, db.BeginTx(context.Background(), &ntx))
|
||||
require.NoError(t, ntx.Rollback())
|
||||
require.Error(t, ntx.Rollback())
|
||||
}
|
||||
|
||||
func prepTempDB(t *testing.T) *DB {
|
||||
name := uniuri.NewLenChars(10, []byte("abcdefghijklmnopqrstuvwxyz"))
|
||||
|
||||
opts := Options{
|
||||
DSN: fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", _pgUser, _pgPass, _pgDSN, name),
|
||||
}
|
||||
|
||||
tmpDB, err := sqlx.Connect(
|
||||
"pgx",
|
||||
fmt.Sprintf("postgres://%s:%s@%s/postgres?sslmode=disable", _pgUser, _pgPass, _pgDSN),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tmpDB.Exec(fmt.Sprintf("CREATE DATABASE %s", name))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tmpDB.Close())
|
||||
|
||||
db, err := New(slog.New(slog.DiscardHandler), metricutil.NewFactory("test", nil), opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
db.Close() //nolint:errcheck,gosec // error provides no meaningful info
|
||||
})
|
||||
|
||||
return db
|
||||
}
|
||||
309
server/core/internal/db/document.go
Normal file
309
server/core/internal/db/document.go
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/oxynote/heimdall/internal/document"
|
||||
"github.com/oxynote/purse/util/sqlutil"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// _insertDocumentSortIndexConstraint names the unique index on
|
||||
// (fk_parent_id, sort_index). Concurrent InsertDocument calls under
|
||||
// the same parent race on this constraint; InsertDocument catches
|
||||
// it and retries.
|
||||
const _insertDocumentSortIndexConstraint = "documents_fk_parent_id_sort_index_key"
|
||||
|
||||
// _insertDocumentMaxAttempts caps the retry loop in InsertDocument
|
||||
// when concurrent inserts race on sort_index. Five attempts covers
|
||||
// the AI assistant's typical fan-out (a handful of parallel
|
||||
// create_document calls) without risking livelock.
|
||||
const _insertDocumentMaxAttempts = 5
|
||||
|
||||
// InsertDocument inserts a new document into the database. Concurrent
|
||||
// inserts under the same parent race on the (fk_parent_id, sort_index)
|
||||
// unique constraint; the unique index guarantees correctness and a
|
||||
// short retry loop smooths over the lost race.
|
||||
func (a *agent) InsertDocument(ctx context.Context, doc document.Document) error {
|
||||
for range _insertDocumentMaxAttempts {
|
||||
err := a.insertDocumentRow(ctx, doc)
|
||||
if err == nil {
|
||||
return a.insertDocumentBranch(ctx, doc)
|
||||
}
|
||||
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) &&
|
||||
pgErr.Code == "23505" &&
|
||||
pgErr.ConstraintName == _insertDocumentSortIndexConstraint {
|
||||
continue
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"InsertDocument: exhausted %d retries on sort_index race",
|
||||
_insertDocumentMaxAttempts,
|
||||
)
|
||||
}
|
||||
|
||||
// insertDocumentRow performs one attempt: count siblings to pick the
|
||||
// next sort_index, then insert. Caller is expected to retry on a
|
||||
// unique-violation on the sort_index constraint.
|
||||
func (a *agent) insertDocumentRow(ctx context.Context, doc document.Document) error {
|
||||
var pos int64
|
||||
|
||||
q, args := a.builder.Select("COUNT(*)").From("documents").
|
||||
Where(sq.Eq{
|
||||
"fk_parent_id": doc.ParentID,
|
||||
}).MustSql()
|
||||
|
||||
if err := sqlx.GetContext(ctx, a.sql, &pos, q, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q, args = a.builder.Insert("documents").
|
||||
SetMap(map[string]any{
|
||||
"id": doc.ID,
|
||||
"sort_index": pos,
|
||||
"fk_organization_id": doc.OrganizationID,
|
||||
"fk_parent_id": doc.ParentID,
|
||||
"created_at": doc.CreatedAt,
|
||||
"fk_created_by": doc.CreatedBy,
|
||||
"updated_at": doc.UpdatedAt,
|
||||
"fk_last_updated_by": doc.LastUpdatedBy,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// CheckDocumentExists returns nil if the document exists and belongs to the
|
||||
// given organization, or sql.ErrNoRows if not found.
|
||||
func (a *agent) CheckDocumentExists(ctx context.Context, id xid.ID, organizationID string) error {
|
||||
var n int
|
||||
|
||||
q, args := a.builder.Select("1").From("documents").
|
||||
Where(sq.Eq{
|
||||
"id": id,
|
||||
"fk_organization_id": organizationID,
|
||||
}).
|
||||
Limit(1).
|
||||
MustSql()
|
||||
|
||||
return sqlx.GetContext(ctx, a.sql, &n, q, args...)
|
||||
}
|
||||
|
||||
// FetchDocument retrieves a document by its ID and organization ID,
|
||||
// joined against the specified branch.
|
||||
func (a *agent) FetchDocument(ctx context.Context, id xid.ID, organizationID string, branchName string) (*document.Document, error) {
|
||||
q, args := a.selectDocumentWithBranch(a.builder.Select(), branchName).
|
||||
Where(sq.Eq{
|
||||
"documents.id": id,
|
||||
"documents.fk_organization_id": organizationID,
|
||||
}).
|
||||
Limit(1).
|
||||
MustSql()
|
||||
|
||||
doc := &document.Document{}
|
||||
|
||||
if err := sqlx.GetContext(ctx, a.sql, doc, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// UpdateDocumentTree updates the tree of a document childrens in the database.
|
||||
func (a *agent) UpdateDocumentTree(ctx context.Context, ss document.Summaries, organizationID string) error {
|
||||
return sqlutil.WrapTx(ctx, a.sql, func(tx *sqlx.Tx) error {
|
||||
for i, st := range ss {
|
||||
q, args := a.builder.Update("documents").
|
||||
SetMap(map[string]any{
|
||||
"sort_index": i,
|
||||
}).
|
||||
Where(sq.Eq{
|
||||
"id": st.ID,
|
||||
"fk_organization_id": organizationID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
_, err := tx.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// FetchDocumentTree fetches the document tree.
|
||||
func (a *agent) FetchDocumentTree(ctx context.Context, organizationID string) (document.Summaries, error) {
|
||||
q, args := a.selectDocumentTree(a.builder.Select(), organizationID).
|
||||
MustSql()
|
||||
|
||||
return a.fetchDocumentTree(ctx, null.Value[xid.ID]{}, q, args...)
|
||||
}
|
||||
|
||||
// FetchDocumentTreeByDocumentParentID fetchs the document tree for the given parent id.
|
||||
func (a *agent) FetchDocumentTreeByDocumentParentID(ctx context.Context, parentID null.Value[xid.ID], organizationID string) (document.Summaries, error) {
|
||||
q, args := a.selectDocumentTree(a.builder.Select(), organizationID).
|
||||
Where(sq.Eq{
|
||||
"documents.fk_parent_id": parentID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
return a.fetchDocumentTree(ctx, parentID, q, args...)
|
||||
}
|
||||
|
||||
// UpdateDocument updates the branch of an existing document in the database.
|
||||
func (a *agent) UpdateDocument(ctx context.Context, doc document.Document) error {
|
||||
return sqlutil.WrapTx(ctx, a.sql, func(tx *sqlx.Tx) error {
|
||||
q, args := a.builder.Update("documents").
|
||||
SetMap(map[string]any{
|
||||
"updated_at": doc.UpdatedAt,
|
||||
"fk_last_updated_by": doc.LastUpdatedBy,
|
||||
}).
|
||||
Where(sq.Eq{
|
||||
"id": doc.ID,
|
||||
"fk_organization_id": doc.OrganizationID,
|
||||
}).MustSql()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := a.upsertDocumentBranch(ctx, doc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return a.insertDocumentBranchChangelog(ctx, doc.ID, doc.BranchName, doc.Changelog())
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDocumentParentID updates the parent ID of a document in the database.
|
||||
func (a *agent) UpdateDocumentParentID(ctx context.Context, id xid.ID, parentID null.Value[xid.ID], organizationID string) error {
|
||||
q, args := a.builder.Update("documents").
|
||||
SetMap(map[string]any{
|
||||
"fk_parent_id": parentID,
|
||||
}).
|
||||
Where(sq.Eq{
|
||||
"id": id,
|
||||
"fk_organization_id": organizationID,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteDocument deletes a document from the database.
|
||||
func (a *agent) DeleteDocument(ctx context.Context, id xid.ID, organizationID string) error {
|
||||
q, args := a.builder.Delete("documents").
|
||||
Where(sq.Eq{
|
||||
"id": id,
|
||||
"fk_organization_id": organizationID,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// fetchDocumentTree fetchs the document tree for the given query and arguments.
|
||||
func (a *agent) fetchDocumentTree(ctx context.Context, rootID null.Value[xid.ID], q string, args ...any) (document.Summaries, error) {
|
||||
var data []struct {
|
||||
*document.Summary
|
||||
|
||||
ParentID null.Value[xid.ID] `db:"fk_parent_id"`
|
||||
}
|
||||
|
||||
if err := sqlx.SelectContext(ctx, a.sql, &data, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
root []xid.ID
|
||||
documents = make(map[xid.ID]*document.Summary)
|
||||
childrens = make(map[xid.ID][]xid.ID)
|
||||
)
|
||||
|
||||
for _, d := range data {
|
||||
for _, childID := range childrens[d.ID] {
|
||||
d.Children = append(d.Children, *documents[childID])
|
||||
documents[childID] = &d.Children[len(d.Children)-1]
|
||||
}
|
||||
|
||||
if d.ParentID == rootID {
|
||||
root = append(root, d.ID)
|
||||
documents[d.ID] = d.Summary
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
parent, ok := documents[d.ParentID.V]
|
||||
if !ok {
|
||||
childrens[d.ParentID.V] = append(childrens[d.ParentID.V], d.ID)
|
||||
documents[d.ID] = d.Summary
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
parent.Children = append(parent.Children, *d.Summary)
|
||||
documents[d.ID] = &parent.Children[len(parent.Children)-1]
|
||||
}
|
||||
|
||||
res := make(document.Summaries, 0, len(root))
|
||||
|
||||
for _, id := range root {
|
||||
res = append(res, *documents[id])
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// selectDocumentTree prepares a sql select statement for fetching the document tree.
|
||||
func (a *agent) selectDocumentTree(b sq.SelectBuilder, organizationID string) sq.SelectBuilder {
|
||||
return b.Columns(
|
||||
`documents.id AS "id"`,
|
||||
`db.document_name AS "document_name"`,
|
||||
`db.icon AS "icon"`,
|
||||
`db.protected AS "protected"`,
|
||||
`documents.fk_parent_id AS "fk_parent_id"`,
|
||||
).From("documents").
|
||||
LeftJoin("document_branches db ON db.fk_document_id = documents.id AND db.branch_name = ?", document.DefaultBranch).
|
||||
Where(sq.Eq{
|
||||
"documents.fk_organization_id": organizationID,
|
||||
}).
|
||||
OrderBy("documents.sort_index")
|
||||
}
|
||||
|
||||
// selectDocumentWithBranch prepares a sql select statement for fetching a document
|
||||
// with a specific branch.
|
||||
func (a *agent) selectDocumentWithBranch(b sq.SelectBuilder, branchName string) sq.SelectBuilder {
|
||||
return b.Columns(
|
||||
`db.id AS "branch_id"`,
|
||||
`documents.id AS "id"`,
|
||||
`documents.fk_organization_id AS "fk_organization_id"`,
|
||||
`documents.fk_parent_id AS "fk_parent_id"`,
|
||||
`db.branch_name AS "branch_name"`,
|
||||
`db.document_name AS "document_name"`,
|
||||
`db.icon AS "icon"`,
|
||||
`db.content AS "content"`,
|
||||
`db.raw_content AS "raw_content"`,
|
||||
`db.protected AS "protected"`,
|
||||
`db."default" AS "default"`,
|
||||
`db.created_at AS "created_at"`,
|
||||
`db.fk_created_by AS "fk_created_by"`,
|
||||
`db.updated_at AS "updated_at"`,
|
||||
`db.fk_last_updated_by AS "fk_last_updated_by"`,
|
||||
).From("documents").
|
||||
Join("document_branches db ON db.fk_document_id = documents.id AND db.branch_name = ?", branchName)
|
||||
}
|
||||
336
server/core/internal/db/document_branch.go
Normal file
336
server/core/internal/db/document_branch.go
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/oxynote/heimdall/internal/document"
|
||||
"github.com/oxynote/purse/util/timeutil"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// insertDocumentBranch inserts a branch row for a newly created document.
|
||||
// Called inside the same transaction as InsertDocument.
|
||||
func (a *agent) insertDocumentBranch(ctx context.Context, doc document.Document) error {
|
||||
q, args := a.builder.Insert("document_branches").
|
||||
SetMap(map[string]any{
|
||||
"id": doc.BranchID,
|
||||
"fk_document_id": doc.ID,
|
||||
"fk_organization_id": doc.OrganizationID,
|
||||
"branch_name": doc.BranchName,
|
||||
"document_name": doc.DocumentName,
|
||||
"icon": doc.Icon,
|
||||
"content": doc.Content,
|
||||
"protected": doc.Protected,
|
||||
`"default"`: doc.Default,
|
||||
"raw_content": doc.RawContent,
|
||||
"created_at": doc.CreatedAt,
|
||||
"fk_created_by": doc.CreatedBy,
|
||||
"updated_at": doc.UpdatedAt,
|
||||
"fk_last_updated_by": doc.LastUpdatedBy,
|
||||
}).
|
||||
Suffix("ON CONFLICT (fk_document_id, branch_name) DO NOTHING").
|
||||
MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// upsertDocumentBranch upserts the branch row for a document.
|
||||
// Called inside the same transaction as UpdateDocument.
|
||||
func (a *agent) upsertDocumentBranch(ctx context.Context, doc document.Document) error {
|
||||
q, args := a.builder.Insert("document_branches").
|
||||
SetMap(map[string]any{
|
||||
"id": doc.BranchID,
|
||||
"fk_document_id": doc.ID,
|
||||
"fk_organization_id": doc.OrganizationID,
|
||||
"branch_name": doc.BranchName,
|
||||
"document_name": doc.DocumentName,
|
||||
"icon": doc.Icon,
|
||||
"content": doc.Content,
|
||||
"protected": doc.Protected,
|
||||
`"default"`: doc.Default,
|
||||
"raw_content": doc.RawContent,
|
||||
"created_at": doc.CreatedAt,
|
||||
"fk_created_by": doc.CreatedBy,
|
||||
"updated_at": doc.UpdatedAt,
|
||||
"fk_last_updated_by": doc.LastUpdatedBy,
|
||||
}).
|
||||
// "default" is intentionally omitted from the SET clause — it is
|
||||
// set once at branch creation and must never change via upsert.
|
||||
Suffix("ON CONFLICT (fk_document_id, branch_name) DO UPDATE SET " +
|
||||
"document_name = excluded.document_name, icon = excluded.icon, " +
|
||||
"content = excluded.content, raw_content = excluded.raw_content, " +
|
||||
"protected = excluded.protected, updated_at = excluded.updated_at, " +
|
||||
"fk_last_updated_by = excluded.fk_last_updated_by").
|
||||
MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// ForkDocumentBranch creates a new branch by copying the contents of an
|
||||
// existing source branch. Does nothing if the target branch already exists.
|
||||
func (a *agent) ForkDocumentBranch(
|
||||
ctx context.Context,
|
||||
docID xid.ID,
|
||||
orgID string,
|
||||
sourceBranch string,
|
||||
targetBranch string,
|
||||
createdBy string,
|
||||
) error {
|
||||
now := timeutil.Now()
|
||||
|
||||
q := `
|
||||
INSERT INTO document_branches (
|
||||
id, fk_document_id, fk_organization_id, branch_name, document_name, icon,
|
||||
content, raw_content, protected, "default",
|
||||
created_at, fk_created_by, updated_at, fk_last_updated_by
|
||||
)
|
||||
SELECT $1, $2, $3, $4, document_name, icon, content, raw_content, protected, false, $5, $6, $5, $6
|
||||
FROM document_branches
|
||||
WHERE fk_document_id = $2 AND branch_name = $7
|
||||
ON CONFLICT (fk_document_id, branch_name) DO NOTHING
|
||||
`
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, xid.New(), docID, orgID, targetBranch, now, createdBy, sourceBranch)
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchMainBranchContent returns the parsed content and display name
|
||||
// of a document's main branch, scoped to the given org. Used by the
|
||||
// assistant's read tools, which only need these fields and would
|
||||
// otherwise pay to deserialize raw_content and the rest of the
|
||||
// branch row.
|
||||
func (a *agent) FetchMainBranchContent(ctx context.Context, docID xid.ID, organizationID string) (document.DocumentContent, error) {
|
||||
q, args := a.builder.Select(
|
||||
`document_name AS "document_name"`,
|
||||
`content AS "content"`,
|
||||
).From("document_branches").
|
||||
Where(sq.Eq{
|
||||
"fk_document_id": docID,
|
||||
"fk_organization_id": organizationID,
|
||||
"branch_name": document.DefaultBranch,
|
||||
}).
|
||||
Limit(1).
|
||||
MustSql()
|
||||
|
||||
out := document.DocumentContent{
|
||||
OrganizationID: organizationID,
|
||||
DocumentID: docID,
|
||||
}
|
||||
if err := sqlx.GetContext(ctx, a.sql, &out, q, args...); err != nil {
|
||||
return document.DocumentContent{}, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FetchDocumentBranches fetches all branches for a document as lightweight summaries.
|
||||
func (a *agent) FetchDocumentBranches(ctx context.Context, docID xid.ID, organizationID string) ([]document.BranchSummary, error) {
|
||||
q, args := a.builder.Select(
|
||||
`id AS "branch_id"`,
|
||||
`branch_name AS "branch_name"`,
|
||||
`document_name AS "document_name"`,
|
||||
`icon AS "icon"`,
|
||||
`protected AS "protected"`,
|
||||
`"default" AS "default"`,
|
||||
`created_at AS "created_at"`,
|
||||
`updated_at AS "updated_at"`,
|
||||
).From("document_branches").
|
||||
Where(sq.Eq{
|
||||
"fk_document_id": docID,
|
||||
"fk_organization_id": organizationID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
branches := []document.BranchSummary{}
|
||||
|
||||
if err := sqlx.SelectContext(ctx, a.sql, &branches, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
// CountDocumentBranches returns the number of branches for a document.
|
||||
// Used to enforce the minimum of one branch before deletion.
|
||||
func (a *agent) CountDocumentBranches(ctx context.Context, docID xid.ID, organizationID string) (int, error) {
|
||||
q, args := a.builder.Select("COUNT(*)").
|
||||
From("document_branches").
|
||||
Where(sq.Eq{
|
||||
"fk_document_id": docID,
|
||||
"fk_organization_id": organizationID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
var count int
|
||||
|
||||
if err := sqlx.GetContext(ctx, a.sql, &count, q, args...); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// DeleteDocumentBranchByID deletes a branch identified by its ID.
|
||||
// The caller is responsible for ensuring at least one branch remains.
|
||||
func (a *agent) DeleteDocumentBranchByID(ctx context.Context, branchID xid.ID, organizationID string) error {
|
||||
q, args := a.builder.Delete("document_branches").
|
||||
Where(sq.Eq{
|
||||
"id": branchID,
|
||||
"fk_organization_id": organizationID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateDocumentBranchMetadata updates the name and protection status of a branch.
|
||||
// It does not modify content or insert a changelog entry.
|
||||
func (a *agent) UpdateDocumentBranchMetadata(ctx context.Context, doc document.Document) error {
|
||||
q, args := a.builder.Update("document_branches").
|
||||
SetMap(map[string]any{
|
||||
"branch_name": doc.BranchName,
|
||||
"protected": doc.Protected,
|
||||
"updated_at": doc.UpdatedAt,
|
||||
"fk_last_updated_by": doc.LastUpdatedBy,
|
||||
}).
|
||||
Where(sq.Eq{
|
||||
"id": doc.BranchID,
|
||||
"fk_organization_id": doc.OrganizationID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchDocumentByBranchID fetches a document joined against the branch identified by branchID.
|
||||
func (a *agent) FetchDocumentByBranchID(ctx context.Context, branchID xid.ID, organizationID string) (*document.Document, error) {
|
||||
q, args := a.builder.Select(
|
||||
`db.id AS "branch_id"`,
|
||||
`documents.id AS "id"`,
|
||||
`documents.fk_organization_id AS "fk_organization_id"`,
|
||||
`documents.fk_parent_id AS "fk_parent_id"`,
|
||||
`db.branch_name AS "branch_name"`,
|
||||
`db.document_name AS "document_name"`,
|
||||
`db.icon AS "icon"`,
|
||||
`db.content AS "content"`,
|
||||
`db.raw_content AS "raw_content"`,
|
||||
`db.protected AS "protected"`,
|
||||
`db."default" AS "default"`,
|
||||
`db.created_at AS "created_at"`,
|
||||
`db.fk_created_by AS "fk_created_by"`,
|
||||
`db.updated_at AS "updated_at"`,
|
||||
`db.fk_last_updated_by AS "fk_last_updated_by"`,
|
||||
).From("documents").
|
||||
Join("document_branches db ON db.fk_document_id = documents.id").
|
||||
Where(sq.Eq{
|
||||
"db.id": branchID,
|
||||
"documents.fk_organization_id": organizationID,
|
||||
}).
|
||||
Limit(1).
|
||||
MustSql()
|
||||
|
||||
doc := &document.Document{}
|
||||
|
||||
if err := sqlx.GetContext(ctx, a.sql, doc, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// FetchDocumentBranchesUnsafe fetches all branches for a document as lightweight
|
||||
// summaries without checking organization ownership.
|
||||
// This is intended only for internal system use cases.
|
||||
func (a *agent) FetchDocumentBranchesUnsafe(ctx context.Context, docID xid.ID) ([]document.BranchSummary, error) {
|
||||
q, args := a.builder.Select(
|
||||
`id AS "branch_id"`,
|
||||
`branch_name AS "branch_name"`,
|
||||
`document_name AS "document_name"`,
|
||||
`icon AS "icon"`,
|
||||
`protected AS "protected"`,
|
||||
`"default" AS "default"`,
|
||||
`created_at AS "created_at"`,
|
||||
`updated_at AS "updated_at"`,
|
||||
).From("document_branches").
|
||||
Where(sq.Eq{
|
||||
"fk_document_id": docID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
branches := []document.BranchSummary{}
|
||||
|
||||
if err := sqlx.SelectContext(ctx, a.sql, &branches, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
// FetchDocumentUnsafeByBranchID fetches a document joined against the branch
|
||||
// identified by branchID without checking organization ownership.
|
||||
// This is intended only for internal system use cases.
|
||||
func (a *agent) FetchDocumentUnsafeByBranchID(ctx context.Context, branchID xid.ID) (*document.Document, error) {
|
||||
q, args := a.builder.Select(
|
||||
`db.id AS "branch_id"`,
|
||||
`documents.id AS "id"`,
|
||||
`documents.fk_organization_id AS "fk_organization_id"`,
|
||||
`documents.fk_parent_id AS "fk_parent_id"`,
|
||||
`db.branch_name AS "branch_name"`,
|
||||
`db.document_name AS "document_name"`,
|
||||
`db.icon AS "icon"`,
|
||||
`db.content AS "content"`,
|
||||
`db.raw_content AS "raw_content"`,
|
||||
`db.protected AS "protected"`,
|
||||
`db."default" AS "default"`,
|
||||
`db.created_at AS "created_at"`,
|
||||
`db.fk_created_by AS "fk_created_by"`,
|
||||
`db.updated_at AS "updated_at"`,
|
||||
`db.fk_last_updated_by AS "fk_last_updated_by"`,
|
||||
).From("documents").
|
||||
Join("document_branches db ON db.fk_document_id = documents.id").
|
||||
Where(sq.Eq{
|
||||
"db.id": branchID,
|
||||
}).
|
||||
Limit(1).
|
||||
MustSql()
|
||||
|
||||
doc := &document.Document{}
|
||||
|
||||
if err := sqlx.GetContext(ctx, a.sql, doc, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// insertDocumentBranchChangelog inserts a changelog entry for a branch update.
|
||||
func (a *agent) insertDocumentBranchChangelog(
|
||||
ctx context.Context,
|
||||
docID xid.ID,
|
||||
branchName string,
|
||||
clog document.Changelog,
|
||||
) error {
|
||||
q, args := a.builder.Insert("document_branch_changelogs").
|
||||
SetMap(map[string]any{
|
||||
"id": clog.ID,
|
||||
"fk_document_id": docID,
|
||||
"fk_branch_name": branchName,
|
||||
"content": clog.Content,
|
||||
"raw_content": clog.RawContent,
|
||||
"created_at": clog.CreatedAt,
|
||||
}).
|
||||
Suffix("ON CONFLICT (id) DO UPDATE SET " +
|
||||
"content = excluded.content, raw_content = excluded.raw_content, created_at = excluded.created_at").
|
||||
MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
return err
|
||||
}
|
||||
286
server/core/internal/db/document_comment.go
Normal file
286
server/core/internal/db/document_comment.go
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/oxynote/heimdall/internal/document/comment"
|
||||
"github.com/rs/xid"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// InsertDocumentComment inserts a new comment into the database.
|
||||
func (a *agent) InsertDocumentComment(ctx context.Context, c comment.Comment) error {
|
||||
q, args := a.builder.Insert("document_comments").
|
||||
SetMap(map[string]any{
|
||||
"id": c.ID,
|
||||
"fk_document_id": c.DocumentID,
|
||||
"fk_organization_id": c.OrganizationID,
|
||||
"fk_user_id": c.UserID,
|
||||
"fk_branch_id": c.BranchID,
|
||||
"resolved": c.Resolved,
|
||||
"fk_resolved_by": c.ResolvedBy,
|
||||
"anchor_block_id": c.AnchorBlockID,
|
||||
"content": c.Content,
|
||||
"diff_deletion_context": c.DiffDeletionContext,
|
||||
"created_at": c.CreatedAt,
|
||||
"updated_at": c.UpdatedAt,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertDocumentCommentReply inserts a new reply to a comment into the database.
|
||||
func (a *agent) InsertDocumentCommentReply(ctx context.Context, r comment.Reply) error {
|
||||
q, args := a.builder.Insert("document_comment_replies").
|
||||
SetMap(map[string]any{
|
||||
"id": r.ID,
|
||||
"fk_document_comment_id": r.CommentID,
|
||||
"fk_organization_id": r.OrganizationID,
|
||||
"fk_user_id": r.UserID,
|
||||
"content": r.Content,
|
||||
"created_at": r.CreatedAt,
|
||||
"updated_at": r.UpdatedAt,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchDocumentComment retrieves a comment by its ID along with all its replies.
|
||||
func (a *agent) FetchDocumentComment(ctx context.Context, id, documentID xid.ID, organizationID string) (*comment.Comment, error) {
|
||||
q, args := a.selectDocumentComment(a.builder.Select()).
|
||||
Where(sq.Eq{
|
||||
"document_comments.id": id,
|
||||
"document_comments.fk_document_id": documentID,
|
||||
"document_comments.fk_organization_id": organizationID,
|
||||
}).
|
||||
Limit(1).
|
||||
MustSql()
|
||||
|
||||
c := &comment.Comment{}
|
||||
|
||||
if err := sqlx.GetContext(ctx, a.sql, c, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q, args = a.selectDocumentCommentReply(a.builder.Select()).
|
||||
Where(sq.Eq{
|
||||
"document_comment_replies.fk_document_comment_id": id,
|
||||
"document_comment_replies.fk_organization_id": organizationID,
|
||||
}).
|
||||
OrderBy("document_comment_replies.created_at ASC").
|
||||
MustSql()
|
||||
|
||||
replies := make([]comment.Reply, 0)
|
||||
|
||||
if err := sqlx.SelectContext(ctx, a.sql, &replies, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Replies = replies
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// FetchDocumentCommentReply retrieves a reply by its ID.
|
||||
func (a *agent) FetchDocumentCommentReply(ctx context.Context, id, commentID xid.ID, organizationID string) (*comment.Reply, error) {
|
||||
q, args := a.selectDocumentCommentReply(a.builder.Select()).
|
||||
Where(sq.Eq{
|
||||
"document_comment_replies.id": id,
|
||||
"document_comment_replies.fk_document_comment_id": commentID,
|
||||
"document_comment_replies.fk_organization_id": organizationID,
|
||||
}).
|
||||
Limit(1).
|
||||
MustSql()
|
||||
|
||||
r := &comment.Reply{}
|
||||
if err := sqlx.GetContext(ctx, a.sql, r, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// UpdateDocumentComment updates an existing comment in the database.
|
||||
func (a *agent) UpdateDocumentComment(ctx context.Context, c comment.Comment) error {
|
||||
q, args := a.builder.Update("document_comments").
|
||||
SetMap(map[string]any{
|
||||
"content": c.Content,
|
||||
"resolved": c.Resolved,
|
||||
"fk_resolved_by": c.ResolvedBy,
|
||||
"anchor_block_id": c.AnchorBlockID,
|
||||
"updated_at": c.UpdatedAt,
|
||||
}).
|
||||
Where(sq.Eq{
|
||||
"id": c.ID,
|
||||
"fk_document_id": c.DocumentID,
|
||||
"fk_organization_id": c.OrganizationID,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateDocumentCommentReply updates an existing reply in the database.
|
||||
func (a *agent) UpdateDocumentCommentReply(ctx context.Context, r comment.Reply) error {
|
||||
q, args := a.builder.Update("document_comment_replies").
|
||||
SetMap(map[string]any{
|
||||
"content": r.Content,
|
||||
"updated_at": r.UpdatedAt,
|
||||
}).
|
||||
Where(sq.Eq{
|
||||
"id": r.ID,
|
||||
"fk_document_comment_id": r.CommentID,
|
||||
"fk_organization_id": r.OrganizationID,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteDocumentComment deletes a comment from the database.
|
||||
func (a *agent) DeleteDocumentComment(ctx context.Context, id, documentID xid.ID, organizationID string) error {
|
||||
q, args := a.builder.Delete("document_comments").
|
||||
Where(sq.Eq{
|
||||
"id": id,
|
||||
"fk_document_id": documentID,
|
||||
"fk_organization_id": organizationID,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteDocumentCommentReply deletes a reply from the database.
|
||||
func (a *agent) DeleteDocumentCommentReply(ctx context.Context, id, commentID xid.ID, organizationID string) error {
|
||||
q, args := a.builder.Delete("document_comment_replies").
|
||||
Where(sq.Eq{
|
||||
"id": id,
|
||||
"fk_document_comment_id": commentID,
|
||||
"fk_organization_id": organizationID,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ReplaceDocumentComment replaces a comment's content and author with the given reply's data.
|
||||
func (a *agent) ReplaceDocumentComment(ctx context.Context, c comment.Comment) error {
|
||||
q, args := a.builder.Update("document_comments").
|
||||
SetMap(map[string]any{
|
||||
"content": c.Content,
|
||||
"fk_user_id": c.UserID,
|
||||
"updated_at": c.UpdatedAt,
|
||||
"anchor_block_id": c.AnchorBlockID,
|
||||
"created_at": c.CreatedAt,
|
||||
}).
|
||||
Where(sq.Eq{
|
||||
"id": c.ID,
|
||||
"fk_document_id": c.DocumentID,
|
||||
"fk_organization_id": c.OrganizationID,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchDocumentCommentsByBranchID retrieves all comments for a specific branch along with their replies.
|
||||
func (a *agent) FetchDocumentCommentsByBranchID(ctx context.Context, branchID xid.ID, organizationID string) ([]comment.Comment, error) {
|
||||
q, args := a.selectDocumentComment(a.builder.Select()).
|
||||
Where(sq.Eq{
|
||||
"document_comments.fk_branch_id": branchID,
|
||||
"document_comments.fk_organization_id": organizationID,
|
||||
}).
|
||||
OrderBy("document_comments.created_at DESC").
|
||||
MustSql()
|
||||
|
||||
comments := make([]comment.Comment, 0)
|
||||
|
||||
if err := sqlx.SelectContext(ctx, a.sql, &comments, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
eg, ectx := errgroup.WithContext(ctx)
|
||||
|
||||
for i := range comments {
|
||||
eg.Go(func() error {
|
||||
q, args := a.selectDocumentCommentReply(a.builder.Select()).
|
||||
Where(sq.Eq{
|
||||
"document_comment_replies.fk_document_comment_id": comments[i].ID,
|
||||
"document_comment_replies.fk_organization_id": organizationID,
|
||||
}).
|
||||
OrderBy("document_comment_replies.created_at ASC").
|
||||
MustSql()
|
||||
|
||||
var replies []comment.Reply
|
||||
|
||||
if err := sqlx.SelectContext(ectx, a.sql, &replies, q, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
comments[i].Replies = replies
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := eg.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return comments, nil
|
||||
}
|
||||
|
||||
// DeleteDocumentCommentsByBranchID deletes all comments for a specific branch.
|
||||
func (a *agent) DeleteDocumentCommentsByBranchID(ctx context.Context, branchID xid.ID, organizationID string) error {
|
||||
q, args := a.builder.Delete("document_comments").
|
||||
Where(sq.Eq{
|
||||
"fk_branch_id": branchID,
|
||||
"fk_organization_id": organizationID,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// selectDocumentComment prepares a sql select statement for fetching comments.
|
||||
func (a *agent) selectDocumentComment(b sq.SelectBuilder) sq.SelectBuilder {
|
||||
return b.Columns(
|
||||
`document_comments.id AS "id"`,
|
||||
`document_comments.fk_document_id AS "fk_document_id"`,
|
||||
`document_comments.fk_user_id AS "fk_user_id"`,
|
||||
`document_comments.fk_organization_id AS "fk_organization_id"`,
|
||||
`document_comments.fk_branch_id AS "fk_branch_id"`,
|
||||
`document_comments.fk_resolved_by AS "fk_resolved_by"`,
|
||||
`document_comments.resolved AS "resolved"`,
|
||||
`document_comments.anchor_block_id AS "anchor_block_id"`,
|
||||
`document_comments.content AS "content"`,
|
||||
`document_comments.diff_deletion_context AS "diff_deletion_context"`,
|
||||
`document_comments.created_at AS "created_at"`,
|
||||
`document_comments.updated_at AS "updated_at"`,
|
||||
).From("document_comments")
|
||||
}
|
||||
|
||||
// selectDocumentCommentReply prepares a sql select statement for fetching replies.
|
||||
func (a *agent) selectDocumentCommentReply(b sq.SelectBuilder) sq.SelectBuilder {
|
||||
return b.Columns(
|
||||
`document_comment_replies.id AS "id"`,
|
||||
`document_comment_replies.fk_document_comment_id AS "fk_document_comment_id"`,
|
||||
`document_comment_replies.fk_organization_id AS "fk_organization_id"`,
|
||||
`document_comment_replies.fk_user_id AS "fk_user_id"`,
|
||||
`document_comment_replies.content AS "content"`,
|
||||
`document_comment_replies.created_at AS "created_at"`,
|
||||
`document_comment_replies.updated_at AS "updated_at"`,
|
||||
).From("document_comment_replies")
|
||||
}
|
||||
54
server/core/internal/db/document_file.go
Normal file
54
server/core/internal/db/document_file.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/oxynote/heimdall/internal/document"
|
||||
)
|
||||
|
||||
// InsertDocumentFile inserts a new document file into the database.
|
||||
func (a *agent) InsertDocumentFile(ctx context.Context, f document.File) error {
|
||||
q, args := a.builder.Insert("document_files").
|
||||
SetMap(map[string]any{
|
||||
"id": f.ID,
|
||||
"location": f.Location,
|
||||
"fk_document_id": f.DocumentID,
|
||||
"fk_organization_id": f.OrganizationID,
|
||||
"created_at": f.CreatedAt,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchDocumentFile retrieves a document file by its block ID from the database.
|
||||
func (a *agent) FetchDocumentFile(ctx context.Context, id string, organizationID string) (*document.File, error) {
|
||||
q, args := a.selectDocumentFile(a.builder.Select()).
|
||||
Where(sq.Eq{
|
||||
"document_files.id": id,
|
||||
"document_files.fk_organization_id": organizationID,
|
||||
}).
|
||||
Limit(1).
|
||||
MustSql()
|
||||
|
||||
f := &document.File{}
|
||||
if err := sqlx.GetContext(ctx, a.sql, f, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// selectDocumentFile prepares a sql select statement for fetching document files.
|
||||
func (a *agent) selectDocumentFile(b sq.SelectBuilder) sq.SelectBuilder {
|
||||
return b.Columns(
|
||||
`document_files.id AS "id"`,
|
||||
`document_files.location AS "location"`,
|
||||
`document_files.fk_document_id AS "fk_document_id"`,
|
||||
`document_files.fk_organization_id AS "fk_organization_id"`,
|
||||
`document_files.created_at AS "created_at"`,
|
||||
).From("document_files")
|
||||
}
|
||||
183
server/core/internal/db/document_hook.go
Normal file
183
server/core/internal/db/document_hook.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/oxynote/heimdall/internal/document/hook"
|
||||
"github.com/oxynote/purse/util/timeutil"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// InsertDocumentHook inserts a new document hook into the database.
|
||||
func (a *agent) InsertDocumentHook(ctx context.Context, hk hook.Hook) error {
|
||||
q, args := a.builder.Insert("document_hooks").
|
||||
SetMap(map[string]any{
|
||||
"id": hk.ID,
|
||||
"type": hk.Type,
|
||||
"fk_document_id": hk.DocumentID,
|
||||
"fk_organization_id": hk.OrganizationID,
|
||||
"fk_branch_id": hk.BranchID,
|
||||
"block_id": hk.BlockID,
|
||||
"settings": hk.Settings,
|
||||
"state": hk.State,
|
||||
"score": hk.Score,
|
||||
"created_at": hk.CreatedAt,
|
||||
"updated_at": hk.UpdatedAt,
|
||||
"soft_deleted_at": hk.SoftDeletedAt,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchDocumentHook retrieves a document hook by its ID from the database.
|
||||
func (a *agent) FetchDocumentHook(ctx context.Context, id xid.ID, organizationID string) (*hook.Hook, error) {
|
||||
q, args := a.selectDocumentHook(a.builder.Select()).
|
||||
Where(sq.Eq{
|
||||
"document_hooks.id": id,
|
||||
"document_hooks.fk_organization_id": organizationID,
|
||||
}).
|
||||
Limit(1).
|
||||
MustSql()
|
||||
|
||||
hk := &hook.Hook{}
|
||||
if err := sqlx.GetContext(ctx, a.sql, hk, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return hk, nil
|
||||
}
|
||||
|
||||
// FetchDocumentHooksByDocumentID retrieves all document hooks associated
|
||||
// with a specific document ID from the database.
|
||||
func (a *agent) FetchDocumentHooksByDocumentID(ctx context.Context, documentID xid.ID, organizationID string) ([]hook.Hook, error) {
|
||||
q, args := a.selectDocumentHook(a.builder.Select()).
|
||||
Where(sq.Eq{
|
||||
"document_hooks.fk_document_id": documentID,
|
||||
"document_hooks.fk_organization_id": organizationID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
hooks := []hook.Hook{}
|
||||
if err := sqlx.SelectContext(ctx, a.sql, &hooks, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return hooks, nil
|
||||
}
|
||||
|
||||
// FetchPaginatedDocumentHooks retrieves a list of document hooks
|
||||
// from the database with pagination support.
|
||||
func (a *agent) FetchPaginatedDocumentHooks(ctx context.Context, offsetID xid.ID, limit int64) ([]hook.Hook, error) {
|
||||
b := a.selectDocumentHook(a.builder.Select()).
|
||||
OrderBy("document_hooks.id ASC").
|
||||
Limit(uint64(limit))
|
||||
|
||||
if !offsetID.IsNil() {
|
||||
b = b.Where(sq.Gt{"document_hooks.id": offsetID})
|
||||
}
|
||||
|
||||
q, args := b.MustSql()
|
||||
|
||||
hooks := []hook.Hook{}
|
||||
if err := sqlx.SelectContext(ctx, a.sql, &hooks, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return hooks, nil
|
||||
}
|
||||
|
||||
// UpdateDocumentHook updates an existing document hook in the database.
|
||||
func (a *agent) UpdateDocumentHook(ctx context.Context, hk hook.Hook) error {
|
||||
q, args := a.builder.Update("document_hooks").
|
||||
SetMap(map[string]any{
|
||||
"settings": hk.Settings,
|
||||
"state": hk.State,
|
||||
"score": hk.Score,
|
||||
"updated_at": hk.UpdatedAt,
|
||||
"soft_deleted_at": hk.SoftDeletedAt,
|
||||
}).
|
||||
Where(sq.Eq{
|
||||
"id": hk.ID,
|
||||
"fk_organization_id": hk.OrganizationID,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteDocumentHook deletes a document hook from the database.
|
||||
func (a *agent) DeleteDocumentHook(ctx context.Context, id xid.ID, organizationID string) error {
|
||||
q, args := a.builder.Delete("document_hooks").
|
||||
Where(sq.Eq{
|
||||
"id": id,
|
||||
"fk_organization_id": organizationID,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchDocumentHooksByBranchID retrieves all document hooks for a specific branch.
|
||||
func (a *agent) FetchDocumentHooksByBranchID(ctx context.Context, branchID xid.ID, organizationID string) ([]hook.Hook, error) {
|
||||
q, args := a.selectDocumentHook(a.builder.Select()).
|
||||
Where(sq.Eq{
|
||||
"document_hooks.fk_branch_id": branchID,
|
||||
"document_hooks.fk_organization_id": organizationID,
|
||||
}).
|
||||
Where(sq.Eq{"soft_deleted_at": nil}).
|
||||
MustSql()
|
||||
|
||||
hooks := []hook.Hook{}
|
||||
|
||||
if err := sqlx.SelectContext(ctx, a.sql, &hooks, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return hooks, nil
|
||||
}
|
||||
|
||||
// SoftDeleteDocumentHooksByBranchID marks all hooks for a branch as soft-deleted.
|
||||
// The hook manager job will handle external resource cleanup and eventual DB deletion.
|
||||
func (a *agent) SoftDeleteDocumentHooksByBranchID(ctx context.Context, branchID xid.ID, organizationID string) error {
|
||||
q, args := a.builder.Update("document_hooks").
|
||||
SetMap(map[string]any{
|
||||
"soft_deleted_at": timeutil.Now(),
|
||||
}).
|
||||
Where(sq.Eq{
|
||||
"fk_branch_id": branchID,
|
||||
"fk_organization_id": organizationID,
|
||||
}).
|
||||
Where(sq.Eq{"soft_deleted_at": nil}).
|
||||
MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// selectDocumentHook prepares a sql select statement for fetching document hooks.
|
||||
func (a *agent) selectDocumentHook(b sq.SelectBuilder) sq.SelectBuilder {
|
||||
return b.Columns(
|
||||
`document_hooks.id AS "id"`,
|
||||
`document_hooks.type AS "type"`,
|
||||
`document_hooks.fk_document_id AS "fk_document_id"`,
|
||||
`document_hooks.fk_organization_id AS "fk_organization_id"`,
|
||||
`document_hooks.fk_branch_id AS "fk_branch_id"`,
|
||||
`document_hooks.block_id AS "block_id"`,
|
||||
`document_hooks.settings AS "settings"`,
|
||||
`document_hooks.state AS "state"`,
|
||||
`document_hooks.score AS "score"`,
|
||||
`document_hooks.created_at AS "created_at"`,
|
||||
`document_hooks.updated_at AS "updated_at"`,
|
||||
`document_hooks.soft_deleted_at AS "soft_deleted_at"`,
|
||||
).From("document_hooks")
|
||||
}
|
||||
58
server/core/internal/db/document_maintainers.go
Normal file
58
server/core/internal/db/document_maintainers.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// UpsertDocumentMaintainers updates or inserts maintainers for a document.
|
||||
func (a *agent) UpsertDocumentMaintainers(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error {
|
||||
sb := a.builder.Insert("document_maintainers").
|
||||
Columns(
|
||||
"fk_document_id",
|
||||
"fk_organization_id",
|
||||
"fk_user_id",
|
||||
).Suffix(`ON CONFLICT (fk_document_id, fk_user_id) DO NOTHING`)
|
||||
|
||||
for _, maintainerID := range maintainerIDs {
|
||||
sb = sb.Values(
|
||||
documentID,
|
||||
organizationID,
|
||||
maintainerID,
|
||||
)
|
||||
}
|
||||
|
||||
q, args := sb.MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchDocumentMaintainers retrieves document maintainers by its ID from the database.
|
||||
func (a *agent) FetchDocumentMaintainers(ctx context.Context, documentID xid.ID, organizationID string) ([]string, error) {
|
||||
b := a.selectDocumentMaintainers(a.builder.Select()).
|
||||
Where(sq.Eq{
|
||||
"document_maintainers.fk_document_id": documentID,
|
||||
"document_maintainers.fk_organization_id": organizationID,
|
||||
})
|
||||
|
||||
q, args := b.MustSql()
|
||||
|
||||
maintainers := []string{}
|
||||
if err := sqlx.SelectContext(ctx, a.sql, &maintainers, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return maintainers, nil
|
||||
}
|
||||
|
||||
// selectDocumentMaintainers prepares a sql select statement for fetching document maintainers.
|
||||
func (a *agent) selectDocumentMaintainers(b sq.SelectBuilder) sq.SelectBuilder {
|
||||
return b.Columns(
|
||||
`document_maintainers.fk_user_id AS "fk_user_id"`,
|
||||
).From("document_maintainers")
|
||||
}
|
||||
169
server/core/internal/db/document_reviewers.go
Normal file
169
server/core/internal/db/document_reviewers.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/oxynote/heimdall/internal/document"
|
||||
"github.com/oxynote/purse/util/sqlutil"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// FetchBranchReviewers retrieves all reviewers for a specific branch.
|
||||
func (a *agent) FetchBranchReviewers(ctx context.Context, branchID xid.ID, organizationID string) ([]document.BranchReviewer, error) {
|
||||
q, args := a.builder.Select(
|
||||
"fk_branch_id",
|
||||
"fk_user_id",
|
||||
"fk_organization_id",
|
||||
"currently_approved",
|
||||
"previously_approved",
|
||||
).From("branch_reviewers").
|
||||
Where(sq.Eq{
|
||||
"fk_branch_id": branchID,
|
||||
"fk_organization_id": organizationID,
|
||||
}).MustSql()
|
||||
|
||||
var reviewers []document.BranchReviewer
|
||||
|
||||
if err := sqlx.SelectContext(ctx, a.sql, &reviewers, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return reviewers, nil
|
||||
}
|
||||
|
||||
// FetchBranchReviewer retrieves a single reviewer for a branch by user ID.
|
||||
func (a *agent) FetchBranchReviewer(ctx context.Context, branchID xid.ID, userID string, organizationID string) (*document.BranchReviewer, error) {
|
||||
q, args := a.builder.Select(
|
||||
"fk_branch_id",
|
||||
"fk_user_id",
|
||||
"fk_organization_id",
|
||||
"currently_approved",
|
||||
"previously_approved",
|
||||
).From("branch_reviewers").
|
||||
Where(sq.Eq{
|
||||
"fk_branch_id": branchID,
|
||||
"fk_user_id": userID,
|
||||
"fk_organization_id": organizationID,
|
||||
}).
|
||||
Limit(1).
|
||||
MustSql()
|
||||
|
||||
var reviewer document.BranchReviewer
|
||||
|
||||
if err := sqlx.GetContext(ctx, a.sql, &reviewer, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &reviewer, nil
|
||||
}
|
||||
|
||||
// InsertBranchReviewer inserts a new reviewer for a branch. On conflict, does nothing.
|
||||
func (a *agent) InsertBranchReviewer(ctx context.Context, reviewer document.BranchReviewer) error {
|
||||
q, args := a.builder.Insert("branch_reviewers").
|
||||
SetMap(map[string]any{
|
||||
"fk_branch_id": reviewer.BranchID,
|
||||
"fk_user_id": reviewer.UserID,
|
||||
"fk_organization_id": reviewer.OrganizationID,
|
||||
"currently_approved": reviewer.CurrentlyApproved,
|
||||
"previously_approved": reviewer.PreviouslyApproved,
|
||||
}).
|
||||
Suffix(`ON CONFLICT (fk_branch_id, fk_user_id) DO NOTHING`).
|
||||
MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateBranchReviewer updates the approval state of an existing branch reviewer.
|
||||
func (a *agent) UpdateBranchReviewer(ctx context.Context, reviewer document.BranchReviewer) error {
|
||||
q, args := a.builder.Update("branch_reviewers").
|
||||
SetMap(map[string]any{
|
||||
"currently_approved": reviewer.CurrentlyApproved,
|
||||
}).
|
||||
Where(sq.Eq{
|
||||
"fk_branch_id": reviewer.BranchID,
|
||||
"fk_user_id": reviewer.UserID,
|
||||
"fk_organization_id": reviewer.OrganizationID,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteBranchReviewer removes a reviewer from a branch.
|
||||
func (a *agent) DeleteBranchReviewer(ctx context.Context, branchID xid.ID, userID string, organizationID string) error {
|
||||
q, args := a.builder.Delete("branch_reviewers").
|
||||
Where(sq.Eq{
|
||||
"fk_branch_id": branchID,
|
||||
"fk_user_id": userID,
|
||||
"fk_organization_id": organizationID,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// PromoteBranchApprovals promotes reviewer approvals from one branch to another.
|
||||
// It copies reviewers from fromBranchID into toBranchID, moving current approvals
|
||||
// to previously_approved and resetting currently_approved. The fromBranch reviewers
|
||||
// are cleared afterwards.
|
||||
func (a *agent) PromoteBranchApprovals(ctx context.Context, fromBranchID, toBranchID xid.ID, organizationID string) error {
|
||||
return sqlutil.WrapTx(ctx, a.sql, func(tx *sqlx.Tx) error {
|
||||
// Delete reviewers on the target branch who have never approved on either pass.
|
||||
q1, args1 := a.builder.Delete("branch_reviewers").
|
||||
Where(sq.Eq{
|
||||
"fk_branch_id": toBranchID,
|
||||
"fk_organization_id": organizationID,
|
||||
"previously_approved": false,
|
||||
"currently_approved": false,
|
||||
}).MustSql()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, q1, args1...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Move currently_approved → previously_approved on the target branch.
|
||||
q2, args2 := a.builder.Update("branch_reviewers").
|
||||
SetMap(map[string]any{
|
||||
"previously_approved": sq.Expr("currently_approved"),
|
||||
"currently_approved": false,
|
||||
}).
|
||||
Where(sq.Eq{
|
||||
"fk_branch_id": toBranchID,
|
||||
"fk_organization_id": organizationID,
|
||||
}).MustSql()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, q2, args2...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Upsert source-branch reviewers into the target branch.
|
||||
const q3 = `
|
||||
INSERT INTO branch_reviewers (fk_branch_id, fk_user_id, fk_organization_id, previously_approved, currently_approved)
|
||||
SELECT $1, fk_user_id, fk_organization_id, false, currently_approved
|
||||
FROM branch_reviewers
|
||||
WHERE fk_branch_id = $2 AND fk_organization_id = $3
|
||||
ON CONFLICT (fk_branch_id, fk_user_id) DO UPDATE SET currently_approved = EXCLUDED.currently_approved
|
||||
`
|
||||
|
||||
if _, err := tx.ExecContext(ctx, q3, toBranchID, fromBranchID, organizationID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear all reviewers from the source branch.
|
||||
q4, args4 := a.builder.Delete("branch_reviewers").
|
||||
Where(sq.Eq{
|
||||
"fk_branch_id": fromBranchID,
|
||||
"fk_organization_id": organizationID,
|
||||
}).MustSql()
|
||||
|
||||
_, err := tx.ExecContext(ctx, q4, args4...)
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
53
server/core/internal/db/document_search_job.go
Normal file
53
server/core/internal/db/document_search_job.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/oxynote/heimdall/internal/document/searchgw"
|
||||
)
|
||||
|
||||
// InsertDocumentSearchJob inserts a new document search job into the database.
|
||||
func (a *agent) InsertDocumentSearchJob(ctx context.Context, blockDiff searchgw.BlocksDifference) error {
|
||||
q, args := a.builder.Insert("document_search_jobs").
|
||||
SetMap(map[string]any{
|
||||
"block_diff": blockDiff,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchDocumentSearchJobs retrieves a batch of document search jobs.
|
||||
func (a *agent) FetchDocumentSearchJobs(ctx context.Context, limit int64) ([]searchgw.DocumentSearchJob, error) {
|
||||
q, args := a.builder.Select(
|
||||
"id",
|
||||
"block_diff",
|
||||
).From("document_search_jobs").
|
||||
OrderBy("id ASC").
|
||||
Limit(uint64(limit)).
|
||||
MustSql()
|
||||
|
||||
var jobs []searchgw.DocumentSearchJob
|
||||
|
||||
err := sqlx.SelectContext(ctx, a.sql, &jobs, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// DeleteDocumentSearchJob deletes a document search job from the database.
|
||||
func (a *agent) DeleteDocumentSearchJob(ctx context.Context, id int64) error {
|
||||
q, args := a.builder.Delete("document_search_jobs").
|
||||
Where(sq.Eq{
|
||||
"id": id,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
418
server/core/internal/db/document_test.go
Normal file
418
server/core/internal/db/document_test.go
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/oxynote/heimdall/internal/document"
|
||||
"github.com/oxynote/purse/util/testutil"
|
||||
"github.com/oxynote/purse/util/timeutil"
|
||||
"github.com/rs/xid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func prepDocuments(t *testing.T, db *DB, count int, fn func(int, *document.Document)) []*document.Document {
|
||||
t.Helper()
|
||||
|
||||
res := make([]*document.Document, count)
|
||||
|
||||
now := timeutil.Now().Truncate(time.Second)
|
||||
|
||||
for i := range count {
|
||||
doc := &document.Document{
|
||||
ID: xid.New(),
|
||||
DocumentBranch: document.DocumentBranch{
|
||||
BranchID: xid.New(),
|
||||
BranchName: document.DefaultBranch,
|
||||
DocumentName: "Test Document " + strconv.Itoa(i),
|
||||
Icon: "icon-test",
|
||||
Content: document.RootBlock{
|
||||
Type: "doc",
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: "paragraph",
|
||||
Text: "This is a test document content.",
|
||||
},
|
||||
},
|
||||
},
|
||||
RawContent: []byte("This is a test document content."),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
if fn != nil {
|
||||
fn(i, doc)
|
||||
}
|
||||
|
||||
if doc.OrganizationID == "" {
|
||||
doc.OrganizationID = prepOrganizations(t, db, 1)[0]
|
||||
}
|
||||
|
||||
res[i] = doc
|
||||
|
||||
// Insert into documents table (structural fields only).
|
||||
q, args := db.builder.Insert("documents").
|
||||
SetMap(map[string]any{
|
||||
"id": doc.ID,
|
||||
"sort_index": i,
|
||||
"fk_organization_id": doc.OrganizationID,
|
||||
"fk_parent_id": doc.ParentID,
|
||||
"created_at": doc.CreatedAt,
|
||||
"fk_created_by": doc.CreatedBy,
|
||||
"updated_at": doc.UpdatedAt,
|
||||
"fk_last_updated_by": doc.LastUpdatedBy,
|
||||
}).MustSql()
|
||||
|
||||
_, err := db.sql.Exec(q, args...)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert into document_branches table.
|
||||
q, args = db.builder.Insert("document_branches").
|
||||
SetMap(map[string]any{
|
||||
"id": doc.BranchID,
|
||||
"fk_document_id": doc.ID,
|
||||
"fk_organization_id": doc.OrganizationID,
|
||||
"branch_name": doc.BranchName,
|
||||
"document_name": doc.DocumentName,
|
||||
"icon": doc.Icon,
|
||||
"content": doc.Content,
|
||||
"raw_content": doc.RawContent,
|
||||
"protected": doc.Protected,
|
||||
"created_at": doc.CreatedAt,
|
||||
"fk_created_by": doc.CreatedBy,
|
||||
"updated_at": doc.UpdatedAt,
|
||||
"fk_last_updated_by": doc.LastUpdatedBy,
|
||||
}).MustSql()
|
||||
|
||||
_, err = db.sql.Exec(q, args...)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func Test_agent_InsertDocument(t *testing.T) {
|
||||
now := timeutil.Now().Truncate(time.Second)
|
||||
|
||||
stubDocument := func(organizationID string) document.Document {
|
||||
return document.Document{
|
||||
ID: xid.New(),
|
||||
OrganizationID: organizationID,
|
||||
DocumentBranch: document.DocumentBranch{
|
||||
BranchID: xid.New(),
|
||||
BranchName: document.DefaultBranch,
|
||||
DocumentName: "Test Document 1",
|
||||
Icon: "icon-test2",
|
||||
Content: document.RootBlock{
|
||||
Type: "doc",
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: "paragraph",
|
||||
Text: "This is a test document content.",
|
||||
},
|
||||
},
|
||||
},
|
||||
RawContent: []byte("This is a test document content."),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type tcase struct {
|
||||
Document document.Document
|
||||
Err error
|
||||
}
|
||||
|
||||
cc := map[string]func(*testing.T, *DB) tcase{
|
||||
"Duplicate key": func(t *testing.T, db *DB) tcase {
|
||||
org := prepOrganizations(t, db, 1)[0]
|
||||
doc := prepDocuments(t, db, 1, func(_ int, ndoc *document.Document) {
|
||||
ndoc.OrganizationID = org
|
||||
})[0]
|
||||
|
||||
return tcase{
|
||||
Document: *doc,
|
||||
Err: assert.AnError,
|
||||
}
|
||||
},
|
||||
"Successful insert": func(t *testing.T, db *DB) tcase {
|
||||
org := prepOrganizations(t, db, 1)[0]
|
||||
|
||||
return tcase{
|
||||
Document: stubDocument(org),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
for cn, cfn := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := prepTempDB(t)
|
||||
c := cfn(t, db)
|
||||
|
||||
err := db.InsertDocument(context.Background(), c.Document)
|
||||
testutil.RequireEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var doc document.Document
|
||||
|
||||
q, args := db.selectDocumentWithBranch(db.builder.Select(), document.DefaultBranch).
|
||||
Where(sq.Eq{
|
||||
"documents.id": c.Document.ID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
err = db.sql.Get(&doc, q, args...)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, c.Document, doc)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_agent_FetchDocument(t *testing.T) {
|
||||
db := prepTempDB(t)
|
||||
|
||||
// error - not found
|
||||
res, err := db.FetchDocument(context.Background(), xid.New(), "non-existent-doc-id", document.DefaultBranch)
|
||||
testutil.AssertEqualError(t, sql.ErrNoRows, err)
|
||||
assert.Nil(t, res)
|
||||
|
||||
// success
|
||||
doc := prepDocuments(t, db, 1, nil)[0]
|
||||
|
||||
res, err = db.FetchDocument(context.Background(), doc.ID, doc.OrganizationID, document.DefaultBranch)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, doc, res)
|
||||
}
|
||||
|
||||
func Test_agent_UpdateDocumentTree(t *testing.T) {
|
||||
db := prepTempDB(t)
|
||||
|
||||
var exp1 []document.Summary
|
||||
|
||||
org := prepOrganizations(t, db, 1)[0]
|
||||
docs := prepDocuments(t, db, 3, func(_ int, ndoc *document.Document) {
|
||||
ndoc.OrganizationID = org
|
||||
})
|
||||
|
||||
// Root documents
|
||||
for i, doc := range docs {
|
||||
st := document.Summary{
|
||||
DocumentName: doc.DocumentName,
|
||||
ID: doc.ID,
|
||||
Icon: doc.Icon,
|
||||
Protected: doc.Protected,
|
||||
}
|
||||
|
||||
// Children documents
|
||||
prepDocuments(t, db, i+1, func(_ int, sdoc *document.Document) {
|
||||
sdoc.OrganizationID = org
|
||||
sdoc.ParentID = null.ValueFrom(docs[i].ID)
|
||||
|
||||
st.Children = append(st.Children, document.Summary{
|
||||
ID: sdoc.ID,
|
||||
DocumentName: sdoc.DocumentName,
|
||||
Icon: sdoc.Icon,
|
||||
Protected: sdoc.Protected,
|
||||
})
|
||||
})
|
||||
|
||||
exp1 = append(exp1, st)
|
||||
}
|
||||
|
||||
// success swapping root elements
|
||||
exp1[0], exp1[1] = exp1[1], exp1[0] // swap root elements
|
||||
|
||||
err := db.UpdateDocumentTree(context.Background(), exp1, org)
|
||||
assert.NoError(t, err)
|
||||
|
||||
exp2 := exp1[0].Children
|
||||
|
||||
// success swapping children elements
|
||||
exp2[0], exp2[1] = exp2[1], exp2[0] // swap children elements
|
||||
|
||||
err = db.UpdateDocumentTree(context.Background(), exp2, org)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_agent_FetchDocumentTree(t *testing.T) {
|
||||
db := prepTempDB(t)
|
||||
|
||||
// error - not found
|
||||
res, err := db.FetchDocumentTree(context.Background(), "123")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, res)
|
||||
|
||||
var exp1 document.Summaries
|
||||
|
||||
org := prepOrganizations(t, db, 1)[0]
|
||||
docs := prepDocuments(t, db, 3, func(_ int, ndoc *document.Document) {
|
||||
ndoc.OrganizationID = org
|
||||
})
|
||||
|
||||
// Root documents
|
||||
for i, doc := range docs {
|
||||
st := document.Summary{
|
||||
DocumentName: doc.DocumentName,
|
||||
ID: doc.ID,
|
||||
Icon: doc.Icon,
|
||||
Protected: doc.Protected,
|
||||
}
|
||||
|
||||
// Children documents
|
||||
prepDocuments(t, db, i+1, func(_ int, sdoc *document.Document) {
|
||||
sdoc.OrganizationID = org
|
||||
sdoc.ParentID = null.ValueFrom(docs[i].ID)
|
||||
|
||||
st.Children = append(st.Children, document.Summary{
|
||||
ID: sdoc.ID,
|
||||
DocumentName: sdoc.DocumentName,
|
||||
Icon: sdoc.Icon,
|
||||
Protected: sdoc.Protected,
|
||||
})
|
||||
})
|
||||
|
||||
// Grandchildren documents
|
||||
for i, sst := range st.Children {
|
||||
prepDocuments(t, db, 2, func(_ int, sdoc *document.Document) {
|
||||
sdoc.OrganizationID = org
|
||||
sdoc.ParentID = null.ValueFrom(sst.ID)
|
||||
|
||||
sst.Children = append(sst.Children, document.Summary{
|
||||
ID: sdoc.ID,
|
||||
DocumentName: sdoc.DocumentName,
|
||||
Icon: sdoc.Icon,
|
||||
Protected: sdoc.Protected,
|
||||
})
|
||||
})
|
||||
|
||||
st.Children[i] = sst
|
||||
}
|
||||
|
||||
exp1 = append(exp1, st)
|
||||
}
|
||||
|
||||
// success
|
||||
res, err = db.FetchDocumentTree(context.Background(), org)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, exp1, res)
|
||||
}
|
||||
|
||||
func Test_agent_UpdateDocument(t *testing.T) {
|
||||
type tcase struct {
|
||||
Document document.Document
|
||||
Err error
|
||||
}
|
||||
|
||||
cc := map[string]func(*testing.T, *DB) tcase{
|
||||
"Successful update": func(t *testing.T, db *DB) tcase {
|
||||
doc := prepDocuments(t, db, 1, nil)[0]
|
||||
doc.DocumentName = "Updated Document Name"
|
||||
doc.UpdatedAt = timeutil.Now().Truncate(time.Second)
|
||||
doc.Icon = "updated-icon"
|
||||
|
||||
return tcase{
|
||||
Document: *doc,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
for cn, cfn := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := prepTempDB(t)
|
||||
c := cfn(t, db)
|
||||
|
||||
for range 3 {
|
||||
err := db.UpdateDocument(context.Background(), c.Document)
|
||||
testutil.RequireEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var doc document.Document
|
||||
|
||||
q, args := db.selectDocumentWithBranch(db.builder.Select(), document.DefaultBranch).
|
||||
Where(sq.Eq{
|
||||
"documents.id": c.Document.ID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
err := db.sql.Get(&doc, q, args...)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, c.Document, doc)
|
||||
|
||||
var logs uint64
|
||||
|
||||
q, args = db.builder.Select("COUNT(*)").From("document_branch_changelogs").
|
||||
Where(sq.Eq{
|
||||
"fk_document_id": c.Document.ID,
|
||||
}).MustSql()
|
||||
|
||||
err = db.sql.Get(&logs, q, args...)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, c.Document, doc)
|
||||
assert.Equal(t, uint64(1), logs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_agent_DeleteDocument(t *testing.T) {
|
||||
type tcase struct {
|
||||
ID xid.ID
|
||||
OrganizationID string
|
||||
Err error
|
||||
}
|
||||
|
||||
cc := map[string]func(*testing.T, *DB) tcase{
|
||||
"Successful delete": func(t *testing.T, db *DB) tcase {
|
||||
doc := prepDocuments(t, db, 1, nil)[0]
|
||||
return tcase{
|
||||
ID: doc.ID,
|
||||
OrganizationID: doc.OrganizationID,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
for cn, cfn := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := prepTempDB(t)
|
||||
c := cfn(t, db)
|
||||
|
||||
err := db.DeleteDocument(context.Background(), c.ID, c.OrganizationID)
|
||||
testutil.RequireEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var doc document.Document
|
||||
|
||||
q, args := db.selectDocumentWithBranch(db.builder.Select(), document.DefaultBranch).
|
||||
Where(sq.Eq{
|
||||
"documents.id": c.ID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
err = db.sql.Get(&doc, q, args...)
|
||||
require.Equal(t, sql.ErrNoRows, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
102
server/core/internal/db/github_installation.go
Normal file
102
server/core/internal/db/github_installation.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// InsertGithubInstallation inserts a new Github installation into the database.
|
||||
func (a *agent) InsertGithubInstallation(ctx context.Context, installationID int64) error {
|
||||
q, args := a.builder.Insert("github_installations").
|
||||
SetMap(map[string]any{
|
||||
"installation_id": installationID,
|
||||
}).MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchGithubInstallationByOrganizationID retrieves the installation ID for a given organization ID.
|
||||
func (a *agent) FetchGithubInstallationByOrganizationID(ctx context.Context, organizationID string) (int64, error) {
|
||||
q, args := a.builder.Select("installation_id").
|
||||
From("github_installations").
|
||||
Where(sq.Eq{
|
||||
"fk_organization_id": organizationID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
var installationID int64
|
||||
|
||||
if err := sqlx.GetContext(ctx, a.sql, &installationID, q, args...); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return installationID, nil
|
||||
}
|
||||
|
||||
// FetchGithubInstallationOrganizationID retrieves the organization ID for a given Github installation.
|
||||
func (a *agent) FetchGithubInstallationOrganizationID(ctx context.Context, installationID int64) (null.String, error) {
|
||||
q, args := a.builder.Select("fk_organization_id").
|
||||
From("github_installations").
|
||||
Where(sq.Eq{
|
||||
"installation_id": installationID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
var organizationID null.String
|
||||
|
||||
if err := sqlx.GetContext(ctx, a.sql, &organizationID, q, args...); err != nil {
|
||||
return null.String{}, err
|
||||
}
|
||||
|
||||
return organizationID, nil
|
||||
}
|
||||
|
||||
// UpdateGithubInstallationOrganizationID updates the organization ID for a given Github installation.
|
||||
func (a *agent) UpdateGithubInstallationOrganizationID(ctx context.Context, installationID int64, organizationID string) error {
|
||||
q, args := a.builder.Update("github_installations").
|
||||
SetMap(map[string]any{
|
||||
"fk_organization_id": organizationID,
|
||||
}).
|
||||
Where(sq.Eq{
|
||||
"installation_id": installationID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteGithubInstallation removes a Github installation from the database.
|
||||
func (a *agent) DeleteGithubInstallation(ctx context.Context, installationID int64) error {
|
||||
q, args := a.builder.Delete("github_installations").
|
||||
Where(sq.Eq{
|
||||
"installation_id": installationID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// UnassignGithubInstallationOrganization removes the organization association from a Github installation.
|
||||
func (a *agent) UnassignGithubInstallationOrganization(ctx context.Context, organizationID string) error {
|
||||
q, args := a.builder.Update("github_installations").
|
||||
SetMap(map[string]any{
|
||||
"fk_organization_id": nil,
|
||||
}).
|
||||
Where(sq.Eq{
|
||||
"fk_organization_id": organizationID,
|
||||
}).
|
||||
MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
283
server/core/internal/db/migrations/001_initial.sql
Normal file
283
server/core/internal/db/migrations/001_initial.sql
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
-- +migrate Up
|
||||
|
||||
-- Better-auth-owned identity tables.
|
||||
|
||||
CREATE TABLE users (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
email_verified BOOLEAN NOT NULL,
|
||||
image TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE user_accounts (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
fk_user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
id_token TEXT,
|
||||
access_token_expires_at TIMESTAMPTZ,
|
||||
refresh_token_expires_at TIMESTAMPTZ,
|
||||
scope TEXT,
|
||||
password TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX user_accounts_user_id_idx ON user_accounts (fk_user_id);
|
||||
|
||||
CREATE TABLE user_verifications (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
identifier TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE INDEX user_verifications_identifier_idx ON user_verifications (identifier);
|
||||
|
||||
CREATE TABLE organizations (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
logo TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE organization_members (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
fk_organization_id TEXT NOT NULL REFERENCES organizations ON DELETE CASCADE,
|
||||
fk_user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX organization_members_user_id_idx ON organization_members (fk_user_id);
|
||||
CREATE INDEX organization_members_organization_id_idx ON organization_members (fk_organization_id);
|
||||
|
||||
CREATE TABLE organization_invitations (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
fk_organization_id TEXT NOT NULL REFERENCES organizations ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
role TEXT,
|
||||
status TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
fk_inviter_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX organization_invitations_email_idx ON organization_invitations (email);
|
||||
CREATE INDEX organization_invitations_organization_id_idx ON organization_invitations (fk_organization_id);
|
||||
|
||||
-- Data sources (Prometheus, SQL).
|
||||
|
||||
CREATE TABLE data_sources (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
fk_organization_id TEXT NOT NULL REFERENCES organizations ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
credentials BYTEA NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ,
|
||||
UNIQUE(fk_organization_id, name)
|
||||
);
|
||||
CREATE INDEX data_sources_fk_organization_id_idx ON data_sources (fk_organization_id);
|
||||
|
||||
-- Document tree. Per-branch content lives in document_branches; the
|
||||
-- documents row only carries identity, parent, and audit fields.
|
||||
|
||||
CREATE TABLE documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
sort_index INTEGER NOT NULL,
|
||||
fk_organization_id TEXT NOT NULL REFERENCES organizations ON DELETE CASCADE,
|
||||
fk_parent_id TEXT REFERENCES documents ON DELETE CASCADE,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
fk_created_by TEXT REFERENCES users ON DELETE SET NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
fk_last_updated_by TEXT REFERENCES users ON DELETE SET NULL,
|
||||
UNIQUE(fk_parent_id, sort_index) DEFERRABLE INITIALLY DEFERRED
|
||||
);
|
||||
CREATE INDEX documents_fk_organization_id_idx ON documents (fk_organization_id);
|
||||
CREATE INDEX documents_fk_parent_id_idx ON documents (fk_parent_id);
|
||||
|
||||
CREATE TABLE document_branches (
|
||||
id TEXT NOT NULL,
|
||||
fk_document_id TEXT NOT NULL REFERENCES documents ON DELETE CASCADE,
|
||||
fk_organization_id TEXT NOT NULL REFERENCES organizations ON DELETE CASCADE,
|
||||
branch_name TEXT NOT NULL,
|
||||
document_name TEXT NULL,
|
||||
icon TEXT NULL,
|
||||
content JSONB NULL,
|
||||
raw_content BYTEA NULL,
|
||||
protected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
"default" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
fk_created_by TEXT REFERENCES users ON DELETE SET NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
fk_last_updated_by TEXT REFERENCES users ON DELETE SET NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE (fk_document_id, branch_name)
|
||||
);
|
||||
CREATE INDEX document_branches_fk_organization_id_idx ON document_branches (fk_organization_id);
|
||||
|
||||
CREATE TABLE document_branch_changelogs (
|
||||
id TEXT PRIMARY KEY,
|
||||
fk_document_id TEXT NOT NULL,
|
||||
fk_branch_name TEXT NOT NULL,
|
||||
content JSONB NOT NULL,
|
||||
raw_content BYTEA NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (fk_document_id, fk_branch_name) REFERENCES document_branches (fk_document_id, branch_name) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX document_branch_changelogs_fk_document_id_fk_branch_name_idx ON document_branch_changelogs (fk_document_id, fk_branch_name);
|
||||
|
||||
CREATE TABLE document_hooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
fk_document_id TEXT NOT NULL REFERENCES documents ON DELETE CASCADE,
|
||||
fk_organization_id TEXT NOT NULL REFERENCES organizations ON DELETE CASCADE,
|
||||
fk_branch_id TEXT NOT NULL REFERENCES document_branches(id) ON DELETE CASCADE,
|
||||
block_id TEXT,
|
||||
settings JSONB NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
score INTEGER NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP,
|
||||
soft_deleted_at TIMESTAMP
|
||||
);
|
||||
CREATE INDEX document_hooks_fk_document_id_idx ON document_hooks (fk_document_id);
|
||||
|
||||
CREATE TABLE document_comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
fk_document_id TEXT NOT NULL REFERENCES documents ON DELETE CASCADE,
|
||||
fk_branch_id TEXT NOT NULL REFERENCES document_branches(id) ON DELETE CASCADE,
|
||||
fk_user_id TEXT REFERENCES users ON DELETE SET NULL,
|
||||
fk_organization_id TEXT NOT NULL REFERENCES organizations ON DELETE CASCADE,
|
||||
fk_resolved_by TEXT REFERENCES users ON DELETE SET NULL,
|
||||
anchor_block_id TEXT,
|
||||
resolved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
content JSONB NOT NULL,
|
||||
diff_deletion_context JSONB,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
CREATE INDEX document_comments_fk_document_id_idx ON document_comments (fk_document_id);
|
||||
|
||||
CREATE TABLE document_comment_replies (
|
||||
id TEXT PRIMARY KEY,
|
||||
fk_document_comment_id TEXT NOT NULL REFERENCES document_comments ON DELETE CASCADE,
|
||||
fk_organization_id TEXT NOT NULL REFERENCES organizations ON DELETE CASCADE,
|
||||
fk_user_id TEXT REFERENCES users ON DELETE SET NULL,
|
||||
content JSONB NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
CREATE INDEX document_comment_replies_fk_document_comment_id_idx ON document_comment_replies (fk_document_comment_id);
|
||||
|
||||
CREATE TABLE document_maintainers (
|
||||
fk_document_id TEXT NOT NULL REFERENCES documents ON DELETE CASCADE,
|
||||
fk_user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
fk_organization_id TEXT NOT NULL REFERENCES organizations ON DELETE CASCADE,
|
||||
PRIMARY KEY (fk_document_id, fk_user_id)
|
||||
);
|
||||
CREATE INDEX document_maintainers_fk_document_id_idx ON document_maintainers (fk_document_id);
|
||||
|
||||
CREATE TABLE branch_reviewers (
|
||||
fk_branch_id TEXT NOT NULL REFERENCES document_branches(id) ON DELETE CASCADE,
|
||||
fk_user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
fk_organization_id TEXT NOT NULL REFERENCES organizations ON DELETE CASCADE,
|
||||
currently_approved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
previously_approved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (fk_branch_id, fk_user_id)
|
||||
);
|
||||
CREATE INDEX branch_reviewers_fk_branch_id_idx ON branch_reviewers (fk_branch_id);
|
||||
CREATE INDEX branch_reviewers_fk_organization_id_idx ON branch_reviewers (fk_organization_id);
|
||||
|
||||
CREATE TABLE document_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
location TEXT NOT NULL,
|
||||
fk_document_id TEXT REFERENCES documents ON DELETE SET NULL,
|
||||
fk_organization_id TEXT REFERENCES organizations ON DELETE SET NULL,
|
||||
created_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE INDEX document_files_fk_document_id_idx ON document_files (fk_document_id);
|
||||
CREATE INDEX document_files_fk_organization_id_idx ON document_files (fk_organization_id);
|
||||
|
||||
CREATE TABLE document_search_jobs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
block_diff JSONB NOT NULL
|
||||
);
|
||||
|
||||
-- Slack / GitHub integrations and the per-org notification queue.
|
||||
|
||||
CREATE TABLE slack_apps (
|
||||
team_id TEXT PRIMARY KEY,
|
||||
fk_organization_id TEXT REFERENCES organizations ON DELETE SET NULL,
|
||||
token TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX slack_apps_fk_organization_id_idx ON slack_apps (fk_organization_id);
|
||||
|
||||
CREATE TABLE slack_user_links (
|
||||
slack_user_id TEXT NOT NULL,
|
||||
fk_team_id TEXT NOT NULL REFERENCES slack_apps(team_id) ON DELETE CASCADE,
|
||||
fk_user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
settings BYTEA NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (slack_user_id, fk_team_id)
|
||||
);
|
||||
CREATE INDEX slack_user_links_fk_user_id_idx ON slack_user_links (fk_user_id);
|
||||
CREATE INDEX slack_user_links_fk_team_id_idx ON slack_user_links (fk_team_id);
|
||||
CREATE INDEX slack_user_links_fk_team_id_fk_user_id_idx ON slack_user_links (fk_team_id, fk_user_id);
|
||||
|
||||
CREATE TABLE github_installations (
|
||||
installation_id NUMERIC PRIMARY KEY,
|
||||
fk_organization_id TEXT REFERENCES organizations ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX github_installations_fk_organization_id_idx ON github_installations (fk_organization_id);
|
||||
|
||||
CREATE TABLE slack_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
fk_organization_id TEXT NOT NULL REFERENCES organizations ON DELETE CASCADE,
|
||||
TEXT TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE INDEX slack_messages_fk_organization_id_idx ON slack_messages (fk_organization_id);
|
||||
|
||||
CREATE TABLE notifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL,
|
||||
fk_user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
fk_organization_id TEXT NOT NULL REFERENCES organizations ON DELETE CASCADE,
|
||||
read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX notifications_fk_organization_id_fk_user_id_idx ON notifications (fk_organization_id, fk_user_id);
|
||||
|
||||
-- +migrate Down
|
||||
DROP TABLE notifications;
|
||||
DROP TABLE slack_messages;
|
||||
DROP TABLE github_installations;
|
||||
DROP TABLE slack_user_links;
|
||||
DROP TABLE slack_apps;
|
||||
DROP TABLE document_search_jobs;
|
||||
DROP TABLE document_files;
|
||||
DROP TABLE branch_reviewers;
|
||||
DROP TABLE document_maintainers;
|
||||
DROP TABLE document_comment_replies;
|
||||
DROP TABLE document_comments;
|
||||
DROP TABLE document_hooks;
|
||||
DROP TABLE document_branch_changelogs;
|
||||
DROP TABLE document_branches;
|
||||
DROP TABLE documents;
|
||||
DROP TABLE data_sources;
|
||||
DROP TABLE organization_invitations;
|
||||
DROP TABLE organization_members;
|
||||
DROP TABLE organizations;
|
||||
DROP TABLE user_verifications;
|
||||
DROP TABLE user_accounts;
|
||||
DROP TABLE users;
|
||||
DROP EXTENSION IF EXISTS vector;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user