@cartanova/qgrid-cli 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Worker — Claude CLI stream-json 프로세스 하나를 관리.
3
+ *
4
+ * 기존 SocratsAI ClaudeCliService에서 추출 + 리팩터:
5
+ * - 토큰/모델을 생성자에서 주입
6
+ * - env allowlist (PATH, HOME, TMPDIR, CLAUDE_CODE_OAUTH_TOKEN만)
7
+ * - callCount 추적 + maxCalls 도달 시 프로세스 재활용
8
+ * - QuotaError/TimeoutError/ProcessError 구분
9
+ */
10
+ import { type ChildProcess, spawn } from "node:child_process";
11
+ import { mkdirSync } from "node:fs";
12
+
13
+ import type { CliResult, QueryInput } from "./qgrid.types";
14
+ import { maskToken, ProcessError, QuotaError, TimeoutError } from "./qgrid.types";
15
+
16
+ type PendingRequest = {
17
+ resolve: (value: CliResult) => void;
18
+ reject: (reason: Error) => void;
19
+ timer: ReturnType<typeof setTimeout>;
20
+ };
21
+
22
+ export type WorkerConfig = {
23
+ token: string;
24
+ model: string;
25
+ timeout: number;
26
+ cwd: string;
27
+ maxCalls: number;
28
+ };
29
+
30
+ export class Worker {
31
+ tokenId: string;
32
+ config: WorkerConfig;
33
+ process: ChildProcess | null = null;
34
+ buffer = "";
35
+ pending: PendingRequest | null = null;
36
+ sessionId: string | null = null;
37
+ queue: Array<() => void> = [];
38
+ cwdCreated = false;
39
+ callCount = 0;
40
+ needsRecycle = false;
41
+
42
+ constructor(config: WorkerConfig) {
43
+ this.config = config;
44
+ this.tokenId = config.token;
45
+ }
46
+
47
+ getQueueDepth(): number {
48
+ return this.queue.length + (this.pending ? 1 : 0);
49
+ }
50
+
51
+ rejectPending(err: Error): void {
52
+ if (!this.pending) return;
53
+ this.pending.reject(err);
54
+ clearTimeout(this.pending.timer);
55
+ this.pending = null;
56
+ }
57
+
58
+ ensureProcess(): ChildProcess {
59
+ if (this.process && this.process.exitCode === null && !this.needsRecycle) {
60
+ return this.process;
61
+ }
62
+
63
+ if (this.process && this.process.exitCode === null) {
64
+ this.process.kill();
65
+ this.process = null;
66
+ }
67
+
68
+ const env: NodeJS.ProcessEnv = {
69
+ PATH: process.env.PATH,
70
+ HOME: process.env.HOME,
71
+ TMPDIR: process.env.TMPDIR,
72
+ CLAUDE_CODE_OAUTH_TOKEN: this.config.token,
73
+ };
74
+
75
+ const { cwd } = this.config;
76
+ if (!this.cwdCreated) {
77
+ mkdirSync(cwd, { recursive: true });
78
+ this.cwdCreated = true;
79
+ }
80
+
81
+ const child = spawn(
82
+ "claude",
83
+ [
84
+ "-p",
85
+ "--input-format",
86
+ "stream-json",
87
+ "--output-format",
88
+ "stream-json",
89
+ "--verbose",
90
+ "--max-turns",
91
+ "1",
92
+ "--model",
93
+ this.config.model,
94
+ ],
95
+ { stdio: ["pipe", "pipe", "ignore"], env, cwd },
96
+ );
97
+
98
+ child.stdout?.on("data", (d: Buffer) => {
99
+ this.buffer += d.toString();
100
+ this.processBuffer();
101
+ });
102
+
103
+ child.on("close", () => {
104
+ this.process = null;
105
+ this.rejectPending(
106
+ new ProcessError(`CLI process closed (token: ${maskToken(this.tokenId)})`),
107
+ );
108
+ this.drainQueue();
109
+ });
110
+
111
+ child.on("error", (err) => {
112
+ this.process = null;
113
+ this.rejectPending(
114
+ new ProcessError(`CLI process error: ${err.message} (token: ${maskToken(this.tokenId)})`),
115
+ );
116
+ this.drainQueue();
117
+ });
118
+
119
+ this.process = child;
120
+ this.buffer = "";
121
+ this.sessionId = null;
122
+ this.callCount = 0;
123
+ this.needsRecycle = false;
124
+ return child;
125
+ }
126
+
127
+ processBuffer(): void {
128
+ const lines = this.buffer.split("\n");
129
+ this.buffer = lines.pop() ?? "";
130
+
131
+ lines
132
+ .filter((line) => line.trim() !== "")
133
+ .forEach((line) => {
134
+ try {
135
+ const j = JSON.parse(line);
136
+
137
+ if (j.type === "system" && j.subtype === "init" && j.session_id) {
138
+ this.sessionId = j.session_id;
139
+ }
140
+
141
+ if (j.type === "result" && this.pending) {
142
+ let text: string = j.result ?? "";
143
+ text = text.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
144
+
145
+ if (text.startsWith("You've hit")) {
146
+ this.pending.reject(
147
+ new QuotaError(`Quota exhausted (token: ${maskToken(this.tokenId)})`),
148
+ );
149
+ clearTimeout(this.pending.timer);
150
+ this.pending = null;
151
+ this.drainQueue();
152
+ return;
153
+ }
154
+
155
+ const u = j.usage ?? {};
156
+ this.pending.resolve({
157
+ text,
158
+ usage: {
159
+ input_tokens: u.input_tokens ?? 0,
160
+ output_tokens: u.output_tokens ?? 0,
161
+ cache_creation_input_tokens: u.cache_creation_input_tokens ?? 0,
162
+ cache_read_input_tokens: u.cache_read_input_tokens ?? 0,
163
+ },
164
+ durationMs: j.duration_ms ?? 0,
165
+ costUsd: j.total_cost_usd ?? 0,
166
+ });
167
+ clearTimeout(this.pending.timer);
168
+ this.pending = null;
169
+ this.drainQueue();
170
+ }
171
+ } catch {
172
+ // skip non-json lines
173
+ }
174
+ });
175
+ }
176
+
177
+ drainQueue(): void {
178
+ if (this.pending) return;
179
+ const next = this.queue.shift();
180
+ if (next) next();
181
+ }
182
+
183
+ async query(input: QueryInput, timeoutMs?: number): Promise<CliResult> {
184
+ if (this.pending) {
185
+ await new Promise<void>((resolve) => {
186
+ this.queue.push(resolve);
187
+ });
188
+ }
189
+
190
+ const child = this.ensureProcess();
191
+ const timeout = timeoutMs ?? this.config.timeout;
192
+
193
+ this.callCount++;
194
+ if (this.callCount >= this.config.maxCalls) {
195
+ this.needsRecycle = true;
196
+ }
197
+
198
+ const prompt = input.system ? `${input.system}\n\n${input.prompt}` : input.prompt;
199
+
200
+ return new Promise<CliResult>((resolve, reject) => {
201
+ const timer = setTimeout(() => {
202
+ this.pending = null;
203
+ reject(
204
+ new TimeoutError(`Timeout after ${timeout / 1000}s (token: ${maskToken(this.tokenId)})`),
205
+ );
206
+ this.kill();
207
+ }, timeout);
208
+
209
+ this.pending = { resolve, reject, timer };
210
+
211
+ const msg = JSON.stringify({
212
+ type: "user",
213
+ message: { role: "user", content: prompt },
214
+ session_id: this.sessionId ?? "default",
215
+ parent_tool_use_id: null,
216
+ });
217
+
218
+ child.stdin?.write(`${msg}\n`);
219
+ });
220
+ }
221
+
222
+ kill(): void {
223
+ if (this.process && this.process.exitCode === null) {
224
+ this.process.kill();
225
+ }
226
+ this.process = null;
227
+ this.buffer = "";
228
+ this.sessionId = null;
229
+ this.callCount = 0;
230
+ this.needsRecycle = false;
231
+ this.rejectPending(new ProcessError(`Worker killed (token: ${maskToken(this.tokenId)})`));
232
+ this.drainQueue();
233
+ }
234
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @generated
3
+ * 직접 수정하지 마세요.
4
+ */
5
+ /* oxlint-disable */
6
+
7
+ import type { SSRQuery } from "sonamu/ssr";
8
+
9
+ // SSRQuery 헬퍼 함수
10
+ function createSSRQuery(
11
+ modelName: string,
12
+ methodName: string,
13
+ params: any[],
14
+ serviceKey: [string, string],
15
+ ): SSRQuery {
16
+ return { modelName, methodName, params, serviceKey, __brand: "SSRQuery" } as SSRQuery;
17
+ }
18
+
19
+ import { RequestLogListParams } from "./request-log/request-log.types";
20
+ import { TokenSubsetKey, RequestLogSubsetKey } from "./sonamu.generated";
21
+ import { TokenListParams } from "./token/token.types";
22
+
23
+ export namespace TokenService {
24
+ export const getToken = <T extends TokenSubsetKey>(subset: T, id: number): SSRQuery =>
25
+ createSSRQuery("TokenModel", "findById", [subset, id], ["Token", "getToken"]);
26
+
27
+ export const getTokens = <T extends TokenSubsetKey, LP extends TokenListParams>(
28
+ subset: T,
29
+ rawParams?: LP,
30
+ ): SSRQuery =>
31
+ createSSRQuery("TokenModel", "findMany", [subset, rawParams], ["Token", "getTokens"]);
32
+ }
33
+
34
+ export namespace RequestLogService {
35
+ export const getRequestLog = <T extends RequestLogSubsetKey>(subset: T, id: number): SSRQuery =>
36
+ createSSRQuery("RequestLogModel", "findById", [subset, id], ["RequestLog", "getRequestLog"]);
37
+
38
+ export const getRequestLogs = <T extends RequestLogSubsetKey, LP extends RequestLogListParams>(
39
+ subset: T,
40
+ rawParams?: LP,
41
+ ): SSRQuery =>
42
+ createSSRQuery(
43
+ "RequestLogModel",
44
+ "findMany",
45
+ [subset, rawParams],
46
+ ["RequestLog", "getRequestLogs"],
47
+ );
48
+ }
49
+
50
+ export namespace QgridService {
51
+ export const stats = (): SSRQuery =>
52
+ createSSRQuery("QgridFrame", "stats", [], ["Qgrid", "stats"]);
53
+
54
+ export const usage = (tokenName?: string): SSRQuery =>
55
+ createSSRQuery("QgridFrame", "usage", [tokenName], ["Qgrid", "usage"]);
56
+
57
+ export const health = (): SSRQuery =>
58
+ createSSRQuery("QgridFrame", "health", [], ["Qgrid", "health"]);
59
+ }
@@ -0,0 +1,155 @@
1
+ import {
2
+ api,
3
+ asArray,
4
+ BadRequestException,
5
+ BaseModelClass,
6
+ exhaustive,
7
+ type ListResult,
8
+ NotFoundException,
9
+ } from "sonamu";
10
+
11
+ import { SD } from "../../i18n/sd.generated";
12
+ import type { RequestLogSubsetKey, RequestLogSubsetMapping } from "../sonamu.generated";
13
+ import { requestLogLoaderQueries, requestLogSubsetQueries } from "../sonamu.generated.sso";
14
+ import type { RequestLogListParams, RequestLogSaveParams } from "./request-log.types";
15
+
16
+ /*
17
+ RequestLog Model
18
+ */
19
+ class RequestLogModelClass extends BaseModelClass<
20
+ RequestLogSubsetKey,
21
+ RequestLogSubsetMapping,
22
+ typeof requestLogSubsetQueries,
23
+ typeof requestLogLoaderQueries
24
+ > {
25
+ constructor() {
26
+ super("RequestLog", requestLogSubsetQueries, requestLogLoaderQueries);
27
+ }
28
+
29
+ @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"], resourceName: "RequestLog" })
30
+ async findById<T extends RequestLogSubsetKey>(
31
+ subset: T,
32
+ id: number,
33
+ ): Promise<RequestLogSubsetMapping[T]> {
34
+ const { rows } = await this.findMany(subset, {
35
+ id,
36
+ num: 1,
37
+ page: 1,
38
+ });
39
+ if (!rows[0]) {
40
+ throw new NotFoundException(SD("error.entityNotFound")("RequestLog", id));
41
+ }
42
+
43
+ return rows[0];
44
+ }
45
+
46
+ async findOne<T extends RequestLogSubsetKey>(
47
+ subset: T,
48
+ listParams: RequestLogListParams,
49
+ ): Promise<RequestLogSubsetMapping[T] | null> {
50
+ const { rows } = await this.findMany(subset, {
51
+ ...listParams,
52
+ num: 1,
53
+ page: 1,
54
+ });
55
+
56
+ return rows[0] ?? null;
57
+ }
58
+
59
+ @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"], resourceName: "RequestLogs" })
60
+ async findMany<T extends RequestLogSubsetKey, LP extends RequestLogListParams>(
61
+ subset: T,
62
+ rawParams?: LP,
63
+ ): Promise<ListResult<LP, RequestLogSubsetMapping[T]>> {
64
+ // params with defaults
65
+ const params = {
66
+ num: 24,
67
+ page: 1,
68
+ search: "id" as const,
69
+ orderBy: "id-desc" as const,
70
+ ...rawParams,
71
+ } satisfies RequestLogListParams;
72
+
73
+ // build queries
74
+ const { qb, onSubset: _ } = this.getSubsetQueries(subset);
75
+
76
+ // id
77
+ if (params.id) {
78
+ qb.whereIn("request_logs.id", asArray(params.id));
79
+ }
80
+
81
+ // token_name filter (toFilter)
82
+ if (params.token_name) {
83
+ qb.where("request_logs.token_name", params.token_name);
84
+ }
85
+
86
+ // search-keyword
87
+ if (params.search && params.keyword && params.keyword.length > 0) {
88
+ if (params.search === "id") {
89
+ qb.where("request_logs.id", Number(params.keyword));
90
+ } else if (params.search === "token_name") {
91
+ qb.where("request_logs.token_name", "like", `%${params.keyword}%`);
92
+ } else if (params.search === "query") {
93
+ qb.where("request_logs.query", "like", `%${params.keyword}%`);
94
+ } else {
95
+ throw new BadRequestException(SD("error.unknownSearchField")(params.search));
96
+ }
97
+ }
98
+
99
+ // orderBy
100
+ if (params.orderBy) {
101
+ // default orderBy
102
+ if (params.orderBy === "id-desc") {
103
+ qb.orderBy("request_logs.id", "desc");
104
+ } else {
105
+ exhaustive(params.orderBy);
106
+ }
107
+ }
108
+
109
+ const enhancers = this.createEnhancers({
110
+ A: (row) => ({
111
+ ...row,
112
+ // 서브셋별로 virtual 필드 계산로직 추가
113
+ }),
114
+ });
115
+
116
+ return this.executeSubsetQuery({
117
+ subset,
118
+ qb,
119
+ params,
120
+ enhancers,
121
+ debug: false,
122
+ });
123
+ }
124
+
125
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
126
+ async save(spa: RequestLogSaveParams[]): Promise<number[]> {
127
+ const wdb = this.getPuri("w");
128
+
129
+ // register
130
+ spa.forEach((sp) => {
131
+ wdb.ubRegister("request_logs", sp);
132
+ });
133
+
134
+ // transaction
135
+ return wdb.transaction(async (trx) => {
136
+ const ids = await trx.ubUpsert("request_logs");
137
+
138
+ return ids;
139
+ });
140
+ }
141
+
142
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
143
+ async del(ids: number[]): Promise<number> {
144
+ const wdb = this.getPuri("w");
145
+
146
+ // transaction
147
+ await wdb.transaction(async (trx) => {
148
+ return trx.table("request_logs").whereIn("request_logs.id", ids).delete();
149
+ });
150
+
151
+ return ids.length;
152
+ }
153
+ }
154
+
155
+ export const RequestLogModel = new RequestLogModelClass();
@@ -0,0 +1,15 @@
1
+ import type { z } from "zod";
2
+
3
+ import { RequestLogBaseListParams, RequestLogBaseSchema } from "../sonamu.generated";
4
+
5
+ // RequestLog - ListParams
6
+ export const RequestLogListParams = RequestLogBaseListParams;
7
+ export type RequestLogListParams = z.infer<typeof RequestLogListParams>;
8
+
9
+ // RequestLog - SaveParams
10
+ export const RequestLogSaveParams = RequestLogBaseSchema.partial({
11
+ id: true,
12
+ created_at: true,
13
+ token_name: true,
14
+ });
15
+ export type RequestLogSaveParams = z.infer<typeof RequestLogSaveParams>;
@@ -0,0 +1,187 @@
1
+ # @generated
2
+ # 직접 수정하지 마세요.
3
+
4
+ GET {{baseUrl}}/api/token/findById
5
+ ?subset=A
6
+ &id=0
7
+ Content-Type: application/json
8
+
9
+ ###
10
+
11
+ GET {{baseUrl}}/api/token/findMany
12
+ ?subset=A
13
+ &rawParams[num]=24
14
+ &rawParams[page]=1
15
+ &rawParams[search]=id
16
+ &rawParams[keyword]=KEYWORD
17
+ &rawParams[orderBy]=id-desc
18
+ &rawParams[queryMode]=both
19
+ &rawParams[id]=0
20
+ &rawParams[sonamuFilter]=unknown-custom
21
+ &rawParams[token]=TOKEN
22
+ Content-Type: application/json
23
+
24
+ ###
25
+
26
+ POST {{baseUrl}}/api/token/save
27
+ Content-Type: application/json
28
+
29
+ {
30
+ "spa": [
31
+ {
32
+ "id": 0,
33
+ "created_at": "2000-01-01",
34
+ "token": "TOKEN",
35
+ "name": "NAME",
36
+ "refresh_token": null,
37
+ "expires_at": null,
38
+ "account_uuid": null,
39
+ "active": false
40
+ }
41
+ ]
42
+ }
43
+
44
+ ###
45
+
46
+ POST {{baseUrl}}/api/token/del
47
+ Content-Type: application/json
48
+
49
+ {
50
+ "ids": [
51
+ 0
52
+ ]
53
+ }
54
+
55
+ ###
56
+
57
+ GET {{baseUrl}}/api/requestLog/findById
58
+ ?subset=A
59
+ &id=0
60
+ Content-Type: application/json
61
+
62
+ ###
63
+
64
+ GET {{baseUrl}}/api/requestLog/findMany
65
+ ?subset=A
66
+ &rawParams[num]=24
67
+ &rawParams[page]=1
68
+ &rawParams[search]=id
69
+ &rawParams[keyword]=KEYWORD
70
+ &rawParams[orderBy]=id-desc
71
+ &rawParams[queryMode]=both
72
+ &rawParams[id]=0
73
+ &rawParams[sonamuFilter]=unknown-custom
74
+ &rawParams[token_name]=TOKEN_NAME
75
+ Content-Type: application/json
76
+
77
+ ###
78
+
79
+ POST {{baseUrl}}/api/requestLog/save
80
+ Content-Type: application/json
81
+
82
+ {
83
+ "spa": [
84
+ {
85
+ "id": 0,
86
+ "created_at": "2000-01-01",
87
+ "token_name": "TOKEN_NAME",
88
+ "query": "QUERY",
89
+ "response": "RESPONSE",
90
+ "input_tokens": 0,
91
+ "output_tokens": 0,
92
+ "cache_read_tokens": 0,
93
+ "cache_creation_tokens": 0,
94
+ "duration_ms": 0
95
+ }
96
+ ]
97
+ }
98
+
99
+ ###
100
+
101
+ POST {{baseUrl}}/api/requestLog/del
102
+ Content-Type: application/json
103
+
104
+ {
105
+ "ids": [
106
+ 0
107
+ ]
108
+ }
109
+
110
+ ###
111
+
112
+ POST {{baseUrl}}/api/qgrid/query
113
+ Content-Type: application/json
114
+
115
+ {
116
+ "prompt": "PROMPT",
117
+ "system": "SYSTEM",
118
+ "timeout": 0
119
+ }
120
+
121
+ ###
122
+
123
+ GET {{baseUrl}}/api/qgrid/stats
124
+ ?
125
+ Content-Type: application/json
126
+
127
+ ###
128
+
129
+ POST {{baseUrl}}/api/qgrid/addToken
130
+ Content-Type: application/json
131
+
132
+ {
133
+ "token": "TOKEN",
134
+ "name": "NAME",
135
+ "refreshToken": "REFRESHTOKEN"
136
+ }
137
+
138
+ ###
139
+
140
+ POST {{baseUrl}}/api/qgrid/updateToken
141
+ Content-Type: application/json
142
+
143
+ {
144
+ "token": "TOKEN",
145
+ "name": "NAME",
146
+ "newToken": "NEWTOKEN",
147
+ "refreshToken": "REFRESHTOKEN"
148
+ }
149
+
150
+ ###
151
+
152
+ POST {{baseUrl}}/api/qgrid/removeToken
153
+ Content-Type: application/json
154
+
155
+ {
156
+ "token": "TOKEN"
157
+ }
158
+
159
+ ###
160
+
161
+ POST {{baseUrl}}/api/qgrid/toggleToken
162
+ Content-Type: application/json
163
+
164
+ {
165
+ "id": 0
166
+ }
167
+
168
+ ###
169
+
170
+ POST {{baseUrl}}/api/qgrid/oauthStart
171
+ Content-Type: application/json
172
+
173
+ {
174
+ "name": "NAME"
175
+ }
176
+
177
+ ###
178
+
179
+ GET {{baseUrl}}/api/qgrid/usage
180
+ ?tokenName=TOKENNAME
181
+ Content-Type: application/json
182
+
183
+ ###
184
+
185
+ GET {{baseUrl}}/api/qgrid/health
186
+ ?
187
+ Content-Type: application/json
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @generated
3
+ * 직접 수정하지 마세요.
4
+ */
5
+ import { PuriWrapper, DatabaseSchemaExtend, PuriLoaderQueries } from "sonamu";
6
+
7
+ import {
8
+ RequestLogSubsetKey,
9
+ TokenSubsetKey,
10
+ RequestLogBaseSchema,
11
+ TokenBaseSchema,
12
+ } from "./sonamu.generated";
13
+
14
+ // SubsetQuery: RequestLog
15
+ export const requestLogSubsetQueries = {
16
+ A: (qbWrapper: PuriWrapper<DatabaseSchemaExtend>) => {
17
+ return qbWrapper.from("request_logs").select({
18
+ id: "request_logs.id",
19
+ created_at: "request_logs.created_at",
20
+ token_name: "request_logs.token_name",
21
+ query: "request_logs.query",
22
+ response: "request_logs.response",
23
+ input_tokens: "request_logs.input_tokens",
24
+ output_tokens: "request_logs.output_tokens",
25
+ cache_read_tokens: "request_logs.cache_read_tokens",
26
+ cache_creation_tokens: "request_logs.cache_creation_tokens",
27
+ duration_ms: "request_logs.duration_ms",
28
+ });
29
+ },
30
+ };
31
+
32
+ // LoaderQuery: RequestLog
33
+ export const requestLogLoaderQueries = {
34
+ A: [],
35
+ } as const satisfies PuriLoaderQueries<RequestLogSubsetKey>;
36
+
37
+ // SubsetQuery: Token
38
+ export const tokenSubsetQueries = {
39
+ A: (qbWrapper: PuriWrapper<DatabaseSchemaExtend>) => {
40
+ return qbWrapper.from("tokens").select({
41
+ id: "tokens.id",
42
+ created_at: "tokens.created_at",
43
+ token: "tokens.token",
44
+ name: "tokens.name",
45
+ refresh_token: "tokens.refresh_token",
46
+ expires_at: "tokens.expires_at",
47
+ account_uuid: "tokens.account_uuid",
48
+ active: "tokens.active",
49
+ });
50
+ },
51
+ };
52
+
53
+ // LoaderQuery: Token
54
+ export const tokenLoaderQueries = {
55
+ A: [],
56
+ } as const satisfies PuriLoaderQueries<TokenSubsetKey>;
57
+
58
+ // DatabaseSchema
59
+ declare module "sonamu" {
60
+ export interface DatabaseSchemaExtend {
61
+ request_logs: RequestLogBaseSchema;
62
+ tokens: TokenBaseSchema;
63
+ }
64
+
65
+ export interface DatabaseForeignKeys {}
66
+ }