@book000/pixivts-db-mysql 0.56.0 → 0.56.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.
- package/LICENSE +21 -0
- package/dist/index.cjs +183 -0
- package/dist/index.d.cts +433 -0
- package/dist/index.d.ts +433 -0
- package/dist/index.js +168 -0
- package/package.json +7 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Tomachi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var mysql2 = require('drizzle-orm/mysql2');
|
|
4
|
+
var mysql = require('mysql2/promise');
|
|
5
|
+
var mysqlCore = require('drizzle-orm/mysql-core');
|
|
6
|
+
var crypto = require('crypto');
|
|
7
|
+
var drizzleOrm = require('drizzle-orm');
|
|
8
|
+
|
|
9
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
10
|
+
|
|
11
|
+
var mysql__default = /*#__PURE__*/_interopDefault(mysql);
|
|
12
|
+
var crypto__default = /*#__PURE__*/_interopDefault(crypto);
|
|
13
|
+
|
|
14
|
+
var __defProp = Object.defineProperty;
|
|
15
|
+
var __export = (target, all) => {
|
|
16
|
+
for (var name in all)
|
|
17
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// src/schema.ts
|
|
21
|
+
var schema_exports = {};
|
|
22
|
+
__export(schema_exports, {
|
|
23
|
+
responsesTable: () => responsesTable
|
|
24
|
+
});
|
|
25
|
+
var responsesTable = mysqlCore.mysqlTable(
|
|
26
|
+
"responses",
|
|
27
|
+
{
|
|
28
|
+
/** Auto-increment primary key. */
|
|
29
|
+
id: mysqlCore.int("id").autoincrement().primaryKey(),
|
|
30
|
+
/** HTTP method (GET or POST). */
|
|
31
|
+
method: mysqlCore.varchar("method", { length: 10 }).notNull(),
|
|
32
|
+
/** API endpoint path (e.g. /v1/illust/detail). */
|
|
33
|
+
endpoint: mysqlCore.varchar("endpoint", { length: 255 }).notNull(),
|
|
34
|
+
/** Full request URL (may be null for internal requests). */
|
|
35
|
+
url: mysqlCore.text("url"),
|
|
36
|
+
/** SHA-256 hash of the request URL for deduplication. */
|
|
37
|
+
urlHash: mysqlCore.varchar("url_hash", { length: 255 }).notNull(),
|
|
38
|
+
/** Serialised request headers (JSON string). */
|
|
39
|
+
requestHeaders: mysqlCore.longtext("request_headers"),
|
|
40
|
+
/** Request body (URL-encoded string for POST, null for GET). */
|
|
41
|
+
requestBody: mysqlCore.longtext("request_body"),
|
|
42
|
+
/** Response content type ("JSON" or "TEXT"). */
|
|
43
|
+
responseType: mysqlCore.varchar("response_type", { length: 10 }).notNull(),
|
|
44
|
+
/** HTTP response status code. */
|
|
45
|
+
statusCode: mysqlCore.int("status_code").notNull(),
|
|
46
|
+
/** Serialised response headers (JSON string). */
|
|
47
|
+
responseHeaders: mysqlCore.longtext("response_headers"),
|
|
48
|
+
/** Raw response body. */
|
|
49
|
+
responseBody: mysqlCore.longtext("response_body").notNull(),
|
|
50
|
+
/** Timestamp when the record was created. */
|
|
51
|
+
createdAt: mysqlCore.datetime("created_at", { mode: "date", fsp: 3 }).notNull()
|
|
52
|
+
},
|
|
53
|
+
(table) => [
|
|
54
|
+
mysqlCore.uniqueIndex("idx_unique").on(
|
|
55
|
+
table.method,
|
|
56
|
+
table.endpoint,
|
|
57
|
+
table.statusCode,
|
|
58
|
+
table.urlHash
|
|
59
|
+
)
|
|
60
|
+
]
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// src/connection.ts
|
|
64
|
+
function parsePort(value) {
|
|
65
|
+
if (!value) return 3306;
|
|
66
|
+
const parsed = Number.parseInt(value, 10);
|
|
67
|
+
return Number.isNaN(parsed) ? 3306 : parsed;
|
|
68
|
+
}
|
|
69
|
+
function createDbConnection(opts) {
|
|
70
|
+
const pool = mysql__default.default.createPool({
|
|
71
|
+
host: opts.host ?? process.env.RESPONSE_DB_HOSTNAME ?? "localhost",
|
|
72
|
+
port: opts.port ?? parsePort(process.env.RESPONSE_DB_PORT),
|
|
73
|
+
user: opts.user ?? process.env.RESPONSE_DB_USERNAME,
|
|
74
|
+
password: opts.password ?? process.env.RESPONSE_DB_PASSWORD,
|
|
75
|
+
database: opts.database ?? process.env.RESPONSE_DB_DATABASE,
|
|
76
|
+
timezone: "+09:00",
|
|
77
|
+
supportBigNumbers: true,
|
|
78
|
+
bigNumberStrings: true
|
|
79
|
+
});
|
|
80
|
+
const db = mysql2.drizzle(pool, { schema: schema_exports, mode: "default" });
|
|
81
|
+
return { pool, db };
|
|
82
|
+
}
|
|
83
|
+
async function bootstrapSchema(db) {
|
|
84
|
+
await db.execute(drizzleOrm.sql`
|
|
85
|
+
CREATE TABLE IF NOT EXISTS responses (
|
|
86
|
+
id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'Response ID',
|
|
87
|
+
method VARCHAR(10) NOT NULL COMMENT 'HTTP method',
|
|
88
|
+
endpoint VARCHAR(255) NOT NULL COMMENT 'API endpoint path',
|
|
89
|
+
url TEXT NULL COMMENT 'Full request URL',
|
|
90
|
+
url_hash VARCHAR(255) NOT NULL COMMENT 'SHA-256 hash of the request URL',
|
|
91
|
+
request_headers LONGTEXT NULL COMMENT 'Request headers (JSON)',
|
|
92
|
+
request_body LONGTEXT NULL COMMENT 'Request body',
|
|
93
|
+
response_type VARCHAR(10) NOT NULL COMMENT 'Response content type',
|
|
94
|
+
status_code INT NOT NULL COMMENT 'HTTP status code',
|
|
95
|
+
response_headers LONGTEXT NULL COMMENT 'Response headers (JSON)',
|
|
96
|
+
response_body LONGTEXT NOT NULL COMMENT 'Response body',
|
|
97
|
+
created_at DATETIME(3) NOT NULL COMMENT 'Record creation timestamp',
|
|
98
|
+
UNIQUE INDEX idx_unique (method, endpoint, status_code, url_hash)
|
|
99
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
100
|
+
`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/recorder.ts
|
|
104
|
+
function ninetyDaysAgo() {
|
|
105
|
+
return new Date(Date.now() - 90 * 24 * 60 * 60 * 1e3);
|
|
106
|
+
}
|
|
107
|
+
async function addResponse(db, record) {
|
|
108
|
+
const urlToHash = record.url ?? `${record.method}:${record.endpoint}`;
|
|
109
|
+
const urlHash = crypto__default.default.createHash("sha256").update(urlToHash).digest("hex");
|
|
110
|
+
await db.insert(responsesTable).values({
|
|
111
|
+
method: record.method,
|
|
112
|
+
endpoint: record.endpoint,
|
|
113
|
+
url: record.url,
|
|
114
|
+
urlHash,
|
|
115
|
+
requestHeaders: record.requestHeaders,
|
|
116
|
+
requestBody: record.requestBody,
|
|
117
|
+
responseType: record.responseType,
|
|
118
|
+
statusCode: record.statusCode,
|
|
119
|
+
responseHeaders: record.responseHeaders,
|
|
120
|
+
responseBody: record.responseBody,
|
|
121
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
122
|
+
}).onDuplicateKeyUpdate({ set: { id: drizzleOrm.sql`id` } });
|
|
123
|
+
}
|
|
124
|
+
function createRecorderBundle(db, close) {
|
|
125
|
+
const interceptor = (record) => addResponse(db, record);
|
|
126
|
+
return { interceptor, db, close };
|
|
127
|
+
}
|
|
128
|
+
async function createResponseRecorder(opts) {
|
|
129
|
+
const { pool, db } = createDbConnection(opts);
|
|
130
|
+
if (opts.bootstrap) {
|
|
131
|
+
await bootstrapSchema(db);
|
|
132
|
+
}
|
|
133
|
+
return createRecorderBundle(db, () => pool.end());
|
|
134
|
+
}
|
|
135
|
+
async function getResponses(db, filter, range) {
|
|
136
|
+
const since = ninetyDaysAgo();
|
|
137
|
+
const limit = range?.limit ?? 100;
|
|
138
|
+
const offset = ((range?.page ?? 1) - 1) * limit;
|
|
139
|
+
return db.select().from(responsesTable).where(
|
|
140
|
+
drizzleOrm.and(
|
|
141
|
+
drizzleOrm.gte(responsesTable.createdAt, since),
|
|
142
|
+
filter?.method ? drizzleOrm.eq(responsesTable.method, filter.method) : void 0,
|
|
143
|
+
filter?.endpoint ? drizzleOrm.eq(responsesTable.endpoint, filter.endpoint) : void 0,
|
|
144
|
+
filter?.statusCode ? drizzleOrm.eq(responsesTable.statusCode, filter.statusCode) : void 0
|
|
145
|
+
)
|
|
146
|
+
).orderBy(drizzleOrm.desc(responsesTable.createdAt)).limit(limit).offset(offset);
|
|
147
|
+
}
|
|
148
|
+
async function getResponseCount(db, filter) {
|
|
149
|
+
const since = ninetyDaysAgo();
|
|
150
|
+
const rows = await db.select({ value: drizzleOrm.count() }).from(responsesTable).where(
|
|
151
|
+
drizzleOrm.and(
|
|
152
|
+
drizzleOrm.gte(responsesTable.createdAt, since),
|
|
153
|
+
filter?.method ? drizzleOrm.eq(responsesTable.method, filter.method) : void 0,
|
|
154
|
+
filter?.endpoint ? drizzleOrm.eq(responsesTable.endpoint, filter.endpoint) : void 0,
|
|
155
|
+
filter?.statusCode ? drizzleOrm.eq(responsesTable.statusCode, filter.statusCode) : void 0
|
|
156
|
+
)
|
|
157
|
+
);
|
|
158
|
+
return rows[0]?.value ?? 0;
|
|
159
|
+
}
|
|
160
|
+
async function getEndpoints(db) {
|
|
161
|
+
const since = ninetyDaysAgo();
|
|
162
|
+
const rows = await db.select({
|
|
163
|
+
method: responsesTable.method,
|
|
164
|
+
endpoint: responsesTable.endpoint,
|
|
165
|
+
statusCode: responsesTable.statusCode,
|
|
166
|
+
count: drizzleOrm.count()
|
|
167
|
+
}).from(responsesTable).where(drizzleOrm.gte(responsesTable.createdAt, since)).groupBy(
|
|
168
|
+
responsesTable.method,
|
|
169
|
+
responsesTable.endpoint,
|
|
170
|
+
responsesTable.statusCode
|
|
171
|
+
).orderBy(drizzleOrm.desc(drizzleOrm.count()));
|
|
172
|
+
return rows.map((r) => ({ ...r, count: r.count }));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
exports.addResponse = addResponse;
|
|
176
|
+
exports.bootstrapSchema = bootstrapSchema;
|
|
177
|
+
exports.createDbConnection = createDbConnection;
|
|
178
|
+
exports.createRecorderBundle = createRecorderBundle;
|
|
179
|
+
exports.createResponseRecorder = createResponseRecorder;
|
|
180
|
+
exports.getEndpoints = getEndpoints;
|
|
181
|
+
exports.getResponseCount = getResponseCount;
|
|
182
|
+
exports.getResponses = getResponses;
|
|
183
|
+
exports.responsesTable = responsesTable;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/mysql2';
|
|
2
|
+
import mysql from 'mysql2/promise';
|
|
3
|
+
import * as drizzle_orm_mysql_core from 'drizzle-orm/mysql-core';
|
|
4
|
+
import { ResponseInterceptor, ResponseRecord } from '@book000/pixivts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Drizzle ORM schema for the `responses` table.
|
|
8
|
+
*
|
|
9
|
+
* Column names are kept in snake_case to match the legacy TypeORM schema
|
|
10
|
+
* so that existing databases can be used without migration.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The `responses` table stores every HTTP response returned by the pixiv API.
|
|
14
|
+
*
|
|
15
|
+
* A unique composite index on (method, endpoint, status_code, url_hash) ensures
|
|
16
|
+
* that each unique request/response combination is stored only once, regardless
|
|
17
|
+
* of when it was recorded.
|
|
18
|
+
*/
|
|
19
|
+
declare const responsesTable: drizzle_orm_mysql_core.MySqlTableWithColumns<{
|
|
20
|
+
name: "responses";
|
|
21
|
+
schema: undefined;
|
|
22
|
+
columns: {
|
|
23
|
+
id: drizzle_orm_mysql_core.MySqlColumn<{
|
|
24
|
+
name: "id";
|
|
25
|
+
tableName: "responses";
|
|
26
|
+
dataType: "number";
|
|
27
|
+
columnType: "MySqlInt";
|
|
28
|
+
data: number;
|
|
29
|
+
driverParam: string | number;
|
|
30
|
+
notNull: true;
|
|
31
|
+
hasDefault: true;
|
|
32
|
+
isPrimaryKey: true;
|
|
33
|
+
isAutoincrement: true;
|
|
34
|
+
hasRuntimeDefault: false;
|
|
35
|
+
enumValues: undefined;
|
|
36
|
+
baseColumn: never;
|
|
37
|
+
identity: undefined;
|
|
38
|
+
generated: undefined;
|
|
39
|
+
}, {}, {}>;
|
|
40
|
+
method: drizzle_orm_mysql_core.MySqlColumn<{
|
|
41
|
+
name: "method";
|
|
42
|
+
tableName: "responses";
|
|
43
|
+
dataType: "string";
|
|
44
|
+
columnType: "MySqlVarChar";
|
|
45
|
+
data: string;
|
|
46
|
+
driverParam: string | number;
|
|
47
|
+
notNull: true;
|
|
48
|
+
hasDefault: false;
|
|
49
|
+
isPrimaryKey: false;
|
|
50
|
+
isAutoincrement: false;
|
|
51
|
+
hasRuntimeDefault: false;
|
|
52
|
+
enumValues: [string, ...string[]];
|
|
53
|
+
baseColumn: never;
|
|
54
|
+
identity: undefined;
|
|
55
|
+
generated: undefined;
|
|
56
|
+
}, {}, {}>;
|
|
57
|
+
endpoint: drizzle_orm_mysql_core.MySqlColumn<{
|
|
58
|
+
name: "endpoint";
|
|
59
|
+
tableName: "responses";
|
|
60
|
+
dataType: "string";
|
|
61
|
+
columnType: "MySqlVarChar";
|
|
62
|
+
data: string;
|
|
63
|
+
driverParam: string | number;
|
|
64
|
+
notNull: true;
|
|
65
|
+
hasDefault: false;
|
|
66
|
+
isPrimaryKey: false;
|
|
67
|
+
isAutoincrement: false;
|
|
68
|
+
hasRuntimeDefault: false;
|
|
69
|
+
enumValues: [string, ...string[]];
|
|
70
|
+
baseColumn: never;
|
|
71
|
+
identity: undefined;
|
|
72
|
+
generated: undefined;
|
|
73
|
+
}, {}, {}>;
|
|
74
|
+
url: drizzle_orm_mysql_core.MySqlColumn<{
|
|
75
|
+
name: "url";
|
|
76
|
+
tableName: "responses";
|
|
77
|
+
dataType: "string";
|
|
78
|
+
columnType: "MySqlText";
|
|
79
|
+
data: string;
|
|
80
|
+
driverParam: string;
|
|
81
|
+
notNull: false;
|
|
82
|
+
hasDefault: false;
|
|
83
|
+
isPrimaryKey: false;
|
|
84
|
+
isAutoincrement: false;
|
|
85
|
+
hasRuntimeDefault: false;
|
|
86
|
+
enumValues: [string, ...string[]];
|
|
87
|
+
baseColumn: never;
|
|
88
|
+
identity: undefined;
|
|
89
|
+
generated: undefined;
|
|
90
|
+
}, {}, {}>;
|
|
91
|
+
urlHash: drizzle_orm_mysql_core.MySqlColumn<{
|
|
92
|
+
name: "url_hash";
|
|
93
|
+
tableName: "responses";
|
|
94
|
+
dataType: "string";
|
|
95
|
+
columnType: "MySqlVarChar";
|
|
96
|
+
data: string;
|
|
97
|
+
driverParam: string | number;
|
|
98
|
+
notNull: true;
|
|
99
|
+
hasDefault: false;
|
|
100
|
+
isPrimaryKey: false;
|
|
101
|
+
isAutoincrement: false;
|
|
102
|
+
hasRuntimeDefault: false;
|
|
103
|
+
enumValues: [string, ...string[]];
|
|
104
|
+
baseColumn: never;
|
|
105
|
+
identity: undefined;
|
|
106
|
+
generated: undefined;
|
|
107
|
+
}, {}, {}>;
|
|
108
|
+
requestHeaders: drizzle_orm_mysql_core.MySqlColumn<{
|
|
109
|
+
name: "request_headers";
|
|
110
|
+
tableName: "responses";
|
|
111
|
+
dataType: "string";
|
|
112
|
+
columnType: "MySqlText";
|
|
113
|
+
data: string;
|
|
114
|
+
driverParam: string;
|
|
115
|
+
notNull: false;
|
|
116
|
+
hasDefault: false;
|
|
117
|
+
isPrimaryKey: false;
|
|
118
|
+
isAutoincrement: false;
|
|
119
|
+
hasRuntimeDefault: false;
|
|
120
|
+
enumValues: [string, ...string[]];
|
|
121
|
+
baseColumn: never;
|
|
122
|
+
identity: undefined;
|
|
123
|
+
generated: undefined;
|
|
124
|
+
}, {}, {}>;
|
|
125
|
+
requestBody: drizzle_orm_mysql_core.MySqlColumn<{
|
|
126
|
+
name: "request_body";
|
|
127
|
+
tableName: "responses";
|
|
128
|
+
dataType: "string";
|
|
129
|
+
columnType: "MySqlText";
|
|
130
|
+
data: string;
|
|
131
|
+
driverParam: string;
|
|
132
|
+
notNull: false;
|
|
133
|
+
hasDefault: false;
|
|
134
|
+
isPrimaryKey: false;
|
|
135
|
+
isAutoincrement: false;
|
|
136
|
+
hasRuntimeDefault: false;
|
|
137
|
+
enumValues: [string, ...string[]];
|
|
138
|
+
baseColumn: never;
|
|
139
|
+
identity: undefined;
|
|
140
|
+
generated: undefined;
|
|
141
|
+
}, {}, {}>;
|
|
142
|
+
responseType: drizzle_orm_mysql_core.MySqlColumn<{
|
|
143
|
+
name: "response_type";
|
|
144
|
+
tableName: "responses";
|
|
145
|
+
dataType: "string";
|
|
146
|
+
columnType: "MySqlVarChar";
|
|
147
|
+
data: string;
|
|
148
|
+
driverParam: string | number;
|
|
149
|
+
notNull: true;
|
|
150
|
+
hasDefault: false;
|
|
151
|
+
isPrimaryKey: false;
|
|
152
|
+
isAutoincrement: false;
|
|
153
|
+
hasRuntimeDefault: false;
|
|
154
|
+
enumValues: [string, ...string[]];
|
|
155
|
+
baseColumn: never;
|
|
156
|
+
identity: undefined;
|
|
157
|
+
generated: undefined;
|
|
158
|
+
}, {}, {}>;
|
|
159
|
+
statusCode: drizzle_orm_mysql_core.MySqlColumn<{
|
|
160
|
+
name: "status_code";
|
|
161
|
+
tableName: "responses";
|
|
162
|
+
dataType: "number";
|
|
163
|
+
columnType: "MySqlInt";
|
|
164
|
+
data: number;
|
|
165
|
+
driverParam: string | number;
|
|
166
|
+
notNull: true;
|
|
167
|
+
hasDefault: false;
|
|
168
|
+
isPrimaryKey: false;
|
|
169
|
+
isAutoincrement: false;
|
|
170
|
+
hasRuntimeDefault: false;
|
|
171
|
+
enumValues: undefined;
|
|
172
|
+
baseColumn: never;
|
|
173
|
+
identity: undefined;
|
|
174
|
+
generated: undefined;
|
|
175
|
+
}, {}, {}>;
|
|
176
|
+
responseHeaders: drizzle_orm_mysql_core.MySqlColumn<{
|
|
177
|
+
name: "response_headers";
|
|
178
|
+
tableName: "responses";
|
|
179
|
+
dataType: "string";
|
|
180
|
+
columnType: "MySqlText";
|
|
181
|
+
data: string;
|
|
182
|
+
driverParam: string;
|
|
183
|
+
notNull: false;
|
|
184
|
+
hasDefault: false;
|
|
185
|
+
isPrimaryKey: false;
|
|
186
|
+
isAutoincrement: false;
|
|
187
|
+
hasRuntimeDefault: false;
|
|
188
|
+
enumValues: [string, ...string[]];
|
|
189
|
+
baseColumn: never;
|
|
190
|
+
identity: undefined;
|
|
191
|
+
generated: undefined;
|
|
192
|
+
}, {}, {}>;
|
|
193
|
+
responseBody: drizzle_orm_mysql_core.MySqlColumn<{
|
|
194
|
+
name: "response_body";
|
|
195
|
+
tableName: "responses";
|
|
196
|
+
dataType: "string";
|
|
197
|
+
columnType: "MySqlText";
|
|
198
|
+
data: string;
|
|
199
|
+
driverParam: string;
|
|
200
|
+
notNull: true;
|
|
201
|
+
hasDefault: false;
|
|
202
|
+
isPrimaryKey: false;
|
|
203
|
+
isAutoincrement: false;
|
|
204
|
+
hasRuntimeDefault: false;
|
|
205
|
+
enumValues: [string, ...string[]];
|
|
206
|
+
baseColumn: never;
|
|
207
|
+
identity: undefined;
|
|
208
|
+
generated: undefined;
|
|
209
|
+
}, {}, {}>;
|
|
210
|
+
createdAt: drizzle_orm_mysql_core.MySqlColumn<{
|
|
211
|
+
name: "created_at";
|
|
212
|
+
tableName: "responses";
|
|
213
|
+
dataType: "date";
|
|
214
|
+
columnType: "MySqlDateTime";
|
|
215
|
+
data: Date;
|
|
216
|
+
driverParam: string | number;
|
|
217
|
+
notNull: true;
|
|
218
|
+
hasDefault: false;
|
|
219
|
+
isPrimaryKey: false;
|
|
220
|
+
isAutoincrement: false;
|
|
221
|
+
hasRuntimeDefault: false;
|
|
222
|
+
enumValues: undefined;
|
|
223
|
+
baseColumn: never;
|
|
224
|
+
identity: undefined;
|
|
225
|
+
generated: undefined;
|
|
226
|
+
}, {}, {}>;
|
|
227
|
+
};
|
|
228
|
+
dialect: "mysql";
|
|
229
|
+
}>;
|
|
230
|
+
/** Type for inserting a new response record. */
|
|
231
|
+
type NewResponse = typeof responsesTable.$inferInsert;
|
|
232
|
+
/** Type for a selected response record. */
|
|
233
|
+
type ResponseRow = typeof responsesTable.$inferSelect;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* MySQL connection factory for @book000/pixivts-db-mysql.
|
|
237
|
+
*
|
|
238
|
+
* Creates a mysql2 connection pool and wraps it in a Drizzle ORM instance.
|
|
239
|
+
*/
|
|
240
|
+
|
|
241
|
+
/** Options for establishing a MySQL connection. */
|
|
242
|
+
interface ConnectionOptions {
|
|
243
|
+
/**
|
|
244
|
+
* Database hostname.
|
|
245
|
+
* Falls back to the `RESPONSE_DB_HOSTNAME` environment variable.
|
|
246
|
+
*/
|
|
247
|
+
host?: string;
|
|
248
|
+
/**
|
|
249
|
+
* Database port.
|
|
250
|
+
* Falls back to the `RESPONSE_DB_PORT` environment variable (default: 3306).
|
|
251
|
+
*/
|
|
252
|
+
port?: number;
|
|
253
|
+
/**
|
|
254
|
+
* Database username.
|
|
255
|
+
* Falls back to the `RESPONSE_DB_USERNAME` environment variable.
|
|
256
|
+
*/
|
|
257
|
+
user?: string;
|
|
258
|
+
/**
|
|
259
|
+
* Database password.
|
|
260
|
+
* Falls back to the `RESPONSE_DB_PASSWORD` environment variable.
|
|
261
|
+
*/
|
|
262
|
+
password?: string;
|
|
263
|
+
/**
|
|
264
|
+
* Database name.
|
|
265
|
+
* Falls back to the `RESPONSE_DB_DATABASE` environment variable.
|
|
266
|
+
*/
|
|
267
|
+
database?: string;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The Drizzle ORM database instance type returned by `createConnection`.
|
|
271
|
+
*
|
|
272
|
+
* Typed with the `schema` so that relational queries are available.
|
|
273
|
+
*/
|
|
274
|
+
type DbInstance = ReturnType<typeof drizzle<typeof schema>>;
|
|
275
|
+
/**
|
|
276
|
+
* Creates a mysql2 connection pool and returns both the raw pool and the
|
|
277
|
+
* Drizzle ORM wrapper.
|
|
278
|
+
*
|
|
279
|
+
* @param opts - Connection options (fall back to environment variables)
|
|
280
|
+
* @returns `{ pool, db }` — raw pool for `close()`, db for queries
|
|
281
|
+
*/
|
|
282
|
+
declare function createDbConnection(opts: ConnectionOptions): {
|
|
283
|
+
pool: mysql.Pool;
|
|
284
|
+
db: DbInstance;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Response recorder for @book000/pixivts-db-mysql.
|
|
289
|
+
*
|
|
290
|
+
* `createResponseRecorder()` returns a `{ interceptor, db, close }` bundle:
|
|
291
|
+
* - `interceptor` — pass to `PixivClient.of(token, { onResponse: interceptor })`
|
|
292
|
+
* - `db` — the raw Drizzle instance for custom queries
|
|
293
|
+
* - `close()` — shuts down the connection pool
|
|
294
|
+
*
|
|
295
|
+
* The recorder uses Drizzle ORM's `onDuplicateKeyUpdate` to silently ignore
|
|
296
|
+
* duplicate entries (same method + endpoint + statusCode + urlHash).
|
|
297
|
+
*/
|
|
298
|
+
|
|
299
|
+
/** The object returned by `createResponseRecorder()`. */
|
|
300
|
+
interface RecorderBundle {
|
|
301
|
+
/**
|
|
302
|
+
* Response interceptor — pass directly to `PixivClient.of()` as
|
|
303
|
+
* the `onResponse` option.
|
|
304
|
+
*/
|
|
305
|
+
interceptor: ResponseInterceptor;
|
|
306
|
+
/** Drizzle ORM database instance for custom queries. */
|
|
307
|
+
db: DbInstance;
|
|
308
|
+
/** Closes the underlying connection pool. */
|
|
309
|
+
close(): Promise<void>;
|
|
310
|
+
}
|
|
311
|
+
/** Options for `createResponseRecorder()`. */
|
|
312
|
+
interface RecorderOptions extends ConnectionOptions {
|
|
313
|
+
/**
|
|
314
|
+
* If `true`, runs `CREATE TABLE IF NOT EXISTS` before returning.
|
|
315
|
+
* Useful for first-run bootstrapping without a separate migration step.
|
|
316
|
+
*
|
|
317
|
+
* @default false
|
|
318
|
+
*/
|
|
319
|
+
bootstrap?: boolean;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Inserts a response record into the database.
|
|
323
|
+
*
|
|
324
|
+
* If the unique composite index fires (duplicate method + endpoint + statusCode
|
|
325
|
+
* + urlHash), the insert is silently ignored via `ON DUPLICATE KEY UPDATE id = id`.
|
|
326
|
+
*
|
|
327
|
+
* @param db - Drizzle ORM database instance
|
|
328
|
+
* @param record - Response record from the HTTP client interceptor
|
|
329
|
+
*/
|
|
330
|
+
declare function addResponse(db: DbInstance, record: ResponseRecord): Promise<void>;
|
|
331
|
+
/**
|
|
332
|
+
* Creates a `RecorderBundle` from an existing Drizzle instance.
|
|
333
|
+
*
|
|
334
|
+
* Useful for testing — pass a mock `db` and a no-op `close`.
|
|
335
|
+
*
|
|
336
|
+
* @param db - Drizzle ORM database instance
|
|
337
|
+
* @param close - Function that closes the underlying connection
|
|
338
|
+
*/
|
|
339
|
+
declare function createRecorderBundle(db: DbInstance, close: () => Promise<void>): RecorderBundle;
|
|
340
|
+
/**
|
|
341
|
+
* Creates a response recorder that persists every pixiv API response to MySQL.
|
|
342
|
+
*
|
|
343
|
+
* @param opts - Connection and bootstrapping options
|
|
344
|
+
* @returns `{ interceptor, db, close }`
|
|
345
|
+
*
|
|
346
|
+
* @example
|
|
347
|
+
* ```ts
|
|
348
|
+
* const { interceptor, close } = await createResponseRecorder({
|
|
349
|
+
* host: 'localhost',
|
|
350
|
+
* database: 'pixivts',
|
|
351
|
+
* bootstrap: true,
|
|
352
|
+
* })
|
|
353
|
+
* const client = await PixivClient.of(token, { onResponse: interceptor })
|
|
354
|
+
* // ...
|
|
355
|
+
* await close()
|
|
356
|
+
* ```
|
|
357
|
+
*/
|
|
358
|
+
declare function createResponseRecorder(opts: RecorderOptions): Promise<RecorderBundle>;
|
|
359
|
+
/** Filter criteria for response queries. */
|
|
360
|
+
interface ResponseFilter {
|
|
361
|
+
/** HTTP method to filter by (e.g. `"GET"`). Omit to match all methods. */
|
|
362
|
+
method?: string;
|
|
363
|
+
/** API endpoint path to filter by (e.g. `"/v1/illust/detail"`). Omit to match all endpoints. */
|
|
364
|
+
endpoint?: string;
|
|
365
|
+
/** HTTP status code to filter by (e.g. `200`). Omit to match all status codes. */
|
|
366
|
+
statusCode?: number;
|
|
367
|
+
}
|
|
368
|
+
/** Pagination options for `getResponses`. */
|
|
369
|
+
interface RangeOptions {
|
|
370
|
+
/** 1-based page number (default: 1). */
|
|
371
|
+
page?: number;
|
|
372
|
+
/** Items per page (default: 100). */
|
|
373
|
+
limit?: number;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Retrieves response records from the last 90 days.
|
|
377
|
+
*
|
|
378
|
+
* @param db - Drizzle ORM database instance
|
|
379
|
+
* @param filter - Optional filter criteria
|
|
380
|
+
* @param range - Optional pagination options
|
|
381
|
+
* @returns Array of response rows, newest first
|
|
382
|
+
*/
|
|
383
|
+
declare function getResponses(db: DbInstance, filter?: ResponseFilter, range?: RangeOptions): Promise<ResponseRow[]>;
|
|
384
|
+
/**
|
|
385
|
+
* Returns the total count of response records from the last 90 days.
|
|
386
|
+
*
|
|
387
|
+
* @param db - Drizzle ORM database instance
|
|
388
|
+
* @param filter - Optional filter criteria
|
|
389
|
+
* @returns Total row count matching the filter
|
|
390
|
+
*/
|
|
391
|
+
declare function getResponseCount(db: DbInstance, filter?: ResponseFilter): Promise<number>;
|
|
392
|
+
/** An endpoint with its response count. */
|
|
393
|
+
interface EndpointWithCount {
|
|
394
|
+
/** HTTP method (e.g. `"GET"` or `"POST"`). */
|
|
395
|
+
method: string;
|
|
396
|
+
/** API endpoint path (e.g. `"/v1/illust/detail"`). */
|
|
397
|
+
endpoint: string;
|
|
398
|
+
/** HTTP status code returned by the endpoint. */
|
|
399
|
+
statusCode: number;
|
|
400
|
+
/** Number of recorded responses for this (method, endpoint, statusCode) combination. */
|
|
401
|
+
count: number;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Returns all unique (method, endpoint, statusCode) combinations seen in the
|
|
405
|
+
* last 90 days, along with the count of matching records.
|
|
406
|
+
*
|
|
407
|
+
* @param db - Drizzle ORM database instance
|
|
408
|
+
* @returns Endpoints sorted by count descending
|
|
409
|
+
*/
|
|
410
|
+
declare function getEndpoints(db: DbInstance): Promise<EndpointWithCount[]>;
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Schema bootstrapping for @book000/pixivts-db-mysql.
|
|
414
|
+
*
|
|
415
|
+
* Provides a `CREATE TABLE IF NOT EXISTS` helper that can be used at startup
|
|
416
|
+
* without requiring drizzle-kit to be installed in production.
|
|
417
|
+
*
|
|
418
|
+
* For full migrations, use:
|
|
419
|
+
* pnpm drizzle-kit generate
|
|
420
|
+
* pnpm drizzle-kit migrate
|
|
421
|
+
*/
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Creates the `responses` table if it does not already exist.
|
|
425
|
+
*
|
|
426
|
+
* This is a lightweight alternative to running drizzle-kit migrations in
|
|
427
|
+
* environments where the table has not been set up yet.
|
|
428
|
+
*
|
|
429
|
+
* @param db - Drizzle ORM database instance
|
|
430
|
+
*/
|
|
431
|
+
declare function bootstrapSchema(db: DbInstance): Promise<void>;
|
|
432
|
+
|
|
433
|
+
export { type ConnectionOptions, type DbInstance, type EndpointWithCount, type NewResponse, type RangeOptions, type RecorderBundle, type RecorderOptions, type ResponseFilter, type ResponseRow, addResponse, bootstrapSchema, createDbConnection, createRecorderBundle, createResponseRecorder, getEndpoints, getResponseCount, getResponses, responsesTable };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/mysql2';
|
|
2
|
+
import mysql from 'mysql2/promise';
|
|
3
|
+
import * as drizzle_orm_mysql_core from 'drizzle-orm/mysql-core';
|
|
4
|
+
import { ResponseInterceptor, ResponseRecord } from '@book000/pixivts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Drizzle ORM schema for the `responses` table.
|
|
8
|
+
*
|
|
9
|
+
* Column names are kept in snake_case to match the legacy TypeORM schema
|
|
10
|
+
* so that existing databases can be used without migration.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The `responses` table stores every HTTP response returned by the pixiv API.
|
|
14
|
+
*
|
|
15
|
+
* A unique composite index on (method, endpoint, status_code, url_hash) ensures
|
|
16
|
+
* that each unique request/response combination is stored only once, regardless
|
|
17
|
+
* of when it was recorded.
|
|
18
|
+
*/
|
|
19
|
+
declare const responsesTable: drizzle_orm_mysql_core.MySqlTableWithColumns<{
|
|
20
|
+
name: "responses";
|
|
21
|
+
schema: undefined;
|
|
22
|
+
columns: {
|
|
23
|
+
id: drizzle_orm_mysql_core.MySqlColumn<{
|
|
24
|
+
name: "id";
|
|
25
|
+
tableName: "responses";
|
|
26
|
+
dataType: "number";
|
|
27
|
+
columnType: "MySqlInt";
|
|
28
|
+
data: number;
|
|
29
|
+
driverParam: string | number;
|
|
30
|
+
notNull: true;
|
|
31
|
+
hasDefault: true;
|
|
32
|
+
isPrimaryKey: true;
|
|
33
|
+
isAutoincrement: true;
|
|
34
|
+
hasRuntimeDefault: false;
|
|
35
|
+
enumValues: undefined;
|
|
36
|
+
baseColumn: never;
|
|
37
|
+
identity: undefined;
|
|
38
|
+
generated: undefined;
|
|
39
|
+
}, {}, {}>;
|
|
40
|
+
method: drizzle_orm_mysql_core.MySqlColumn<{
|
|
41
|
+
name: "method";
|
|
42
|
+
tableName: "responses";
|
|
43
|
+
dataType: "string";
|
|
44
|
+
columnType: "MySqlVarChar";
|
|
45
|
+
data: string;
|
|
46
|
+
driverParam: string | number;
|
|
47
|
+
notNull: true;
|
|
48
|
+
hasDefault: false;
|
|
49
|
+
isPrimaryKey: false;
|
|
50
|
+
isAutoincrement: false;
|
|
51
|
+
hasRuntimeDefault: false;
|
|
52
|
+
enumValues: [string, ...string[]];
|
|
53
|
+
baseColumn: never;
|
|
54
|
+
identity: undefined;
|
|
55
|
+
generated: undefined;
|
|
56
|
+
}, {}, {}>;
|
|
57
|
+
endpoint: drizzle_orm_mysql_core.MySqlColumn<{
|
|
58
|
+
name: "endpoint";
|
|
59
|
+
tableName: "responses";
|
|
60
|
+
dataType: "string";
|
|
61
|
+
columnType: "MySqlVarChar";
|
|
62
|
+
data: string;
|
|
63
|
+
driverParam: string | number;
|
|
64
|
+
notNull: true;
|
|
65
|
+
hasDefault: false;
|
|
66
|
+
isPrimaryKey: false;
|
|
67
|
+
isAutoincrement: false;
|
|
68
|
+
hasRuntimeDefault: false;
|
|
69
|
+
enumValues: [string, ...string[]];
|
|
70
|
+
baseColumn: never;
|
|
71
|
+
identity: undefined;
|
|
72
|
+
generated: undefined;
|
|
73
|
+
}, {}, {}>;
|
|
74
|
+
url: drizzle_orm_mysql_core.MySqlColumn<{
|
|
75
|
+
name: "url";
|
|
76
|
+
tableName: "responses";
|
|
77
|
+
dataType: "string";
|
|
78
|
+
columnType: "MySqlText";
|
|
79
|
+
data: string;
|
|
80
|
+
driverParam: string;
|
|
81
|
+
notNull: false;
|
|
82
|
+
hasDefault: false;
|
|
83
|
+
isPrimaryKey: false;
|
|
84
|
+
isAutoincrement: false;
|
|
85
|
+
hasRuntimeDefault: false;
|
|
86
|
+
enumValues: [string, ...string[]];
|
|
87
|
+
baseColumn: never;
|
|
88
|
+
identity: undefined;
|
|
89
|
+
generated: undefined;
|
|
90
|
+
}, {}, {}>;
|
|
91
|
+
urlHash: drizzle_orm_mysql_core.MySqlColumn<{
|
|
92
|
+
name: "url_hash";
|
|
93
|
+
tableName: "responses";
|
|
94
|
+
dataType: "string";
|
|
95
|
+
columnType: "MySqlVarChar";
|
|
96
|
+
data: string;
|
|
97
|
+
driverParam: string | number;
|
|
98
|
+
notNull: true;
|
|
99
|
+
hasDefault: false;
|
|
100
|
+
isPrimaryKey: false;
|
|
101
|
+
isAutoincrement: false;
|
|
102
|
+
hasRuntimeDefault: false;
|
|
103
|
+
enumValues: [string, ...string[]];
|
|
104
|
+
baseColumn: never;
|
|
105
|
+
identity: undefined;
|
|
106
|
+
generated: undefined;
|
|
107
|
+
}, {}, {}>;
|
|
108
|
+
requestHeaders: drizzle_orm_mysql_core.MySqlColumn<{
|
|
109
|
+
name: "request_headers";
|
|
110
|
+
tableName: "responses";
|
|
111
|
+
dataType: "string";
|
|
112
|
+
columnType: "MySqlText";
|
|
113
|
+
data: string;
|
|
114
|
+
driverParam: string;
|
|
115
|
+
notNull: false;
|
|
116
|
+
hasDefault: false;
|
|
117
|
+
isPrimaryKey: false;
|
|
118
|
+
isAutoincrement: false;
|
|
119
|
+
hasRuntimeDefault: false;
|
|
120
|
+
enumValues: [string, ...string[]];
|
|
121
|
+
baseColumn: never;
|
|
122
|
+
identity: undefined;
|
|
123
|
+
generated: undefined;
|
|
124
|
+
}, {}, {}>;
|
|
125
|
+
requestBody: drizzle_orm_mysql_core.MySqlColumn<{
|
|
126
|
+
name: "request_body";
|
|
127
|
+
tableName: "responses";
|
|
128
|
+
dataType: "string";
|
|
129
|
+
columnType: "MySqlText";
|
|
130
|
+
data: string;
|
|
131
|
+
driverParam: string;
|
|
132
|
+
notNull: false;
|
|
133
|
+
hasDefault: false;
|
|
134
|
+
isPrimaryKey: false;
|
|
135
|
+
isAutoincrement: false;
|
|
136
|
+
hasRuntimeDefault: false;
|
|
137
|
+
enumValues: [string, ...string[]];
|
|
138
|
+
baseColumn: never;
|
|
139
|
+
identity: undefined;
|
|
140
|
+
generated: undefined;
|
|
141
|
+
}, {}, {}>;
|
|
142
|
+
responseType: drizzle_orm_mysql_core.MySqlColumn<{
|
|
143
|
+
name: "response_type";
|
|
144
|
+
tableName: "responses";
|
|
145
|
+
dataType: "string";
|
|
146
|
+
columnType: "MySqlVarChar";
|
|
147
|
+
data: string;
|
|
148
|
+
driverParam: string | number;
|
|
149
|
+
notNull: true;
|
|
150
|
+
hasDefault: false;
|
|
151
|
+
isPrimaryKey: false;
|
|
152
|
+
isAutoincrement: false;
|
|
153
|
+
hasRuntimeDefault: false;
|
|
154
|
+
enumValues: [string, ...string[]];
|
|
155
|
+
baseColumn: never;
|
|
156
|
+
identity: undefined;
|
|
157
|
+
generated: undefined;
|
|
158
|
+
}, {}, {}>;
|
|
159
|
+
statusCode: drizzle_orm_mysql_core.MySqlColumn<{
|
|
160
|
+
name: "status_code";
|
|
161
|
+
tableName: "responses";
|
|
162
|
+
dataType: "number";
|
|
163
|
+
columnType: "MySqlInt";
|
|
164
|
+
data: number;
|
|
165
|
+
driverParam: string | number;
|
|
166
|
+
notNull: true;
|
|
167
|
+
hasDefault: false;
|
|
168
|
+
isPrimaryKey: false;
|
|
169
|
+
isAutoincrement: false;
|
|
170
|
+
hasRuntimeDefault: false;
|
|
171
|
+
enumValues: undefined;
|
|
172
|
+
baseColumn: never;
|
|
173
|
+
identity: undefined;
|
|
174
|
+
generated: undefined;
|
|
175
|
+
}, {}, {}>;
|
|
176
|
+
responseHeaders: drizzle_orm_mysql_core.MySqlColumn<{
|
|
177
|
+
name: "response_headers";
|
|
178
|
+
tableName: "responses";
|
|
179
|
+
dataType: "string";
|
|
180
|
+
columnType: "MySqlText";
|
|
181
|
+
data: string;
|
|
182
|
+
driverParam: string;
|
|
183
|
+
notNull: false;
|
|
184
|
+
hasDefault: false;
|
|
185
|
+
isPrimaryKey: false;
|
|
186
|
+
isAutoincrement: false;
|
|
187
|
+
hasRuntimeDefault: false;
|
|
188
|
+
enumValues: [string, ...string[]];
|
|
189
|
+
baseColumn: never;
|
|
190
|
+
identity: undefined;
|
|
191
|
+
generated: undefined;
|
|
192
|
+
}, {}, {}>;
|
|
193
|
+
responseBody: drizzle_orm_mysql_core.MySqlColumn<{
|
|
194
|
+
name: "response_body";
|
|
195
|
+
tableName: "responses";
|
|
196
|
+
dataType: "string";
|
|
197
|
+
columnType: "MySqlText";
|
|
198
|
+
data: string;
|
|
199
|
+
driverParam: string;
|
|
200
|
+
notNull: true;
|
|
201
|
+
hasDefault: false;
|
|
202
|
+
isPrimaryKey: false;
|
|
203
|
+
isAutoincrement: false;
|
|
204
|
+
hasRuntimeDefault: false;
|
|
205
|
+
enumValues: [string, ...string[]];
|
|
206
|
+
baseColumn: never;
|
|
207
|
+
identity: undefined;
|
|
208
|
+
generated: undefined;
|
|
209
|
+
}, {}, {}>;
|
|
210
|
+
createdAt: drizzle_orm_mysql_core.MySqlColumn<{
|
|
211
|
+
name: "created_at";
|
|
212
|
+
tableName: "responses";
|
|
213
|
+
dataType: "date";
|
|
214
|
+
columnType: "MySqlDateTime";
|
|
215
|
+
data: Date;
|
|
216
|
+
driverParam: string | number;
|
|
217
|
+
notNull: true;
|
|
218
|
+
hasDefault: false;
|
|
219
|
+
isPrimaryKey: false;
|
|
220
|
+
isAutoincrement: false;
|
|
221
|
+
hasRuntimeDefault: false;
|
|
222
|
+
enumValues: undefined;
|
|
223
|
+
baseColumn: never;
|
|
224
|
+
identity: undefined;
|
|
225
|
+
generated: undefined;
|
|
226
|
+
}, {}, {}>;
|
|
227
|
+
};
|
|
228
|
+
dialect: "mysql";
|
|
229
|
+
}>;
|
|
230
|
+
/** Type for inserting a new response record. */
|
|
231
|
+
type NewResponse = typeof responsesTable.$inferInsert;
|
|
232
|
+
/** Type for a selected response record. */
|
|
233
|
+
type ResponseRow = typeof responsesTable.$inferSelect;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* MySQL connection factory for @book000/pixivts-db-mysql.
|
|
237
|
+
*
|
|
238
|
+
* Creates a mysql2 connection pool and wraps it in a Drizzle ORM instance.
|
|
239
|
+
*/
|
|
240
|
+
|
|
241
|
+
/** Options for establishing a MySQL connection. */
|
|
242
|
+
interface ConnectionOptions {
|
|
243
|
+
/**
|
|
244
|
+
* Database hostname.
|
|
245
|
+
* Falls back to the `RESPONSE_DB_HOSTNAME` environment variable.
|
|
246
|
+
*/
|
|
247
|
+
host?: string;
|
|
248
|
+
/**
|
|
249
|
+
* Database port.
|
|
250
|
+
* Falls back to the `RESPONSE_DB_PORT` environment variable (default: 3306).
|
|
251
|
+
*/
|
|
252
|
+
port?: number;
|
|
253
|
+
/**
|
|
254
|
+
* Database username.
|
|
255
|
+
* Falls back to the `RESPONSE_DB_USERNAME` environment variable.
|
|
256
|
+
*/
|
|
257
|
+
user?: string;
|
|
258
|
+
/**
|
|
259
|
+
* Database password.
|
|
260
|
+
* Falls back to the `RESPONSE_DB_PASSWORD` environment variable.
|
|
261
|
+
*/
|
|
262
|
+
password?: string;
|
|
263
|
+
/**
|
|
264
|
+
* Database name.
|
|
265
|
+
* Falls back to the `RESPONSE_DB_DATABASE` environment variable.
|
|
266
|
+
*/
|
|
267
|
+
database?: string;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The Drizzle ORM database instance type returned by `createConnection`.
|
|
271
|
+
*
|
|
272
|
+
* Typed with the `schema` so that relational queries are available.
|
|
273
|
+
*/
|
|
274
|
+
type DbInstance = ReturnType<typeof drizzle<typeof schema>>;
|
|
275
|
+
/**
|
|
276
|
+
* Creates a mysql2 connection pool and returns both the raw pool and the
|
|
277
|
+
* Drizzle ORM wrapper.
|
|
278
|
+
*
|
|
279
|
+
* @param opts - Connection options (fall back to environment variables)
|
|
280
|
+
* @returns `{ pool, db }` — raw pool for `close()`, db for queries
|
|
281
|
+
*/
|
|
282
|
+
declare function createDbConnection(opts: ConnectionOptions): {
|
|
283
|
+
pool: mysql.Pool;
|
|
284
|
+
db: DbInstance;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Response recorder for @book000/pixivts-db-mysql.
|
|
289
|
+
*
|
|
290
|
+
* `createResponseRecorder()` returns a `{ interceptor, db, close }` bundle:
|
|
291
|
+
* - `interceptor` — pass to `PixivClient.of(token, { onResponse: interceptor })`
|
|
292
|
+
* - `db` — the raw Drizzle instance for custom queries
|
|
293
|
+
* - `close()` — shuts down the connection pool
|
|
294
|
+
*
|
|
295
|
+
* The recorder uses Drizzle ORM's `onDuplicateKeyUpdate` to silently ignore
|
|
296
|
+
* duplicate entries (same method + endpoint + statusCode + urlHash).
|
|
297
|
+
*/
|
|
298
|
+
|
|
299
|
+
/** The object returned by `createResponseRecorder()`. */
|
|
300
|
+
interface RecorderBundle {
|
|
301
|
+
/**
|
|
302
|
+
* Response interceptor — pass directly to `PixivClient.of()` as
|
|
303
|
+
* the `onResponse` option.
|
|
304
|
+
*/
|
|
305
|
+
interceptor: ResponseInterceptor;
|
|
306
|
+
/** Drizzle ORM database instance for custom queries. */
|
|
307
|
+
db: DbInstance;
|
|
308
|
+
/** Closes the underlying connection pool. */
|
|
309
|
+
close(): Promise<void>;
|
|
310
|
+
}
|
|
311
|
+
/** Options for `createResponseRecorder()`. */
|
|
312
|
+
interface RecorderOptions extends ConnectionOptions {
|
|
313
|
+
/**
|
|
314
|
+
* If `true`, runs `CREATE TABLE IF NOT EXISTS` before returning.
|
|
315
|
+
* Useful for first-run bootstrapping without a separate migration step.
|
|
316
|
+
*
|
|
317
|
+
* @default false
|
|
318
|
+
*/
|
|
319
|
+
bootstrap?: boolean;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Inserts a response record into the database.
|
|
323
|
+
*
|
|
324
|
+
* If the unique composite index fires (duplicate method + endpoint + statusCode
|
|
325
|
+
* + urlHash), the insert is silently ignored via `ON DUPLICATE KEY UPDATE id = id`.
|
|
326
|
+
*
|
|
327
|
+
* @param db - Drizzle ORM database instance
|
|
328
|
+
* @param record - Response record from the HTTP client interceptor
|
|
329
|
+
*/
|
|
330
|
+
declare function addResponse(db: DbInstance, record: ResponseRecord): Promise<void>;
|
|
331
|
+
/**
|
|
332
|
+
* Creates a `RecorderBundle` from an existing Drizzle instance.
|
|
333
|
+
*
|
|
334
|
+
* Useful for testing — pass a mock `db` and a no-op `close`.
|
|
335
|
+
*
|
|
336
|
+
* @param db - Drizzle ORM database instance
|
|
337
|
+
* @param close - Function that closes the underlying connection
|
|
338
|
+
*/
|
|
339
|
+
declare function createRecorderBundle(db: DbInstance, close: () => Promise<void>): RecorderBundle;
|
|
340
|
+
/**
|
|
341
|
+
* Creates a response recorder that persists every pixiv API response to MySQL.
|
|
342
|
+
*
|
|
343
|
+
* @param opts - Connection and bootstrapping options
|
|
344
|
+
* @returns `{ interceptor, db, close }`
|
|
345
|
+
*
|
|
346
|
+
* @example
|
|
347
|
+
* ```ts
|
|
348
|
+
* const { interceptor, close } = await createResponseRecorder({
|
|
349
|
+
* host: 'localhost',
|
|
350
|
+
* database: 'pixivts',
|
|
351
|
+
* bootstrap: true,
|
|
352
|
+
* })
|
|
353
|
+
* const client = await PixivClient.of(token, { onResponse: interceptor })
|
|
354
|
+
* // ...
|
|
355
|
+
* await close()
|
|
356
|
+
* ```
|
|
357
|
+
*/
|
|
358
|
+
declare function createResponseRecorder(opts: RecorderOptions): Promise<RecorderBundle>;
|
|
359
|
+
/** Filter criteria for response queries. */
|
|
360
|
+
interface ResponseFilter {
|
|
361
|
+
/** HTTP method to filter by (e.g. `"GET"`). Omit to match all methods. */
|
|
362
|
+
method?: string;
|
|
363
|
+
/** API endpoint path to filter by (e.g. `"/v1/illust/detail"`). Omit to match all endpoints. */
|
|
364
|
+
endpoint?: string;
|
|
365
|
+
/** HTTP status code to filter by (e.g. `200`). Omit to match all status codes. */
|
|
366
|
+
statusCode?: number;
|
|
367
|
+
}
|
|
368
|
+
/** Pagination options for `getResponses`. */
|
|
369
|
+
interface RangeOptions {
|
|
370
|
+
/** 1-based page number (default: 1). */
|
|
371
|
+
page?: number;
|
|
372
|
+
/** Items per page (default: 100). */
|
|
373
|
+
limit?: number;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Retrieves response records from the last 90 days.
|
|
377
|
+
*
|
|
378
|
+
* @param db - Drizzle ORM database instance
|
|
379
|
+
* @param filter - Optional filter criteria
|
|
380
|
+
* @param range - Optional pagination options
|
|
381
|
+
* @returns Array of response rows, newest first
|
|
382
|
+
*/
|
|
383
|
+
declare function getResponses(db: DbInstance, filter?: ResponseFilter, range?: RangeOptions): Promise<ResponseRow[]>;
|
|
384
|
+
/**
|
|
385
|
+
* Returns the total count of response records from the last 90 days.
|
|
386
|
+
*
|
|
387
|
+
* @param db - Drizzle ORM database instance
|
|
388
|
+
* @param filter - Optional filter criteria
|
|
389
|
+
* @returns Total row count matching the filter
|
|
390
|
+
*/
|
|
391
|
+
declare function getResponseCount(db: DbInstance, filter?: ResponseFilter): Promise<number>;
|
|
392
|
+
/** An endpoint with its response count. */
|
|
393
|
+
interface EndpointWithCount {
|
|
394
|
+
/** HTTP method (e.g. `"GET"` or `"POST"`). */
|
|
395
|
+
method: string;
|
|
396
|
+
/** API endpoint path (e.g. `"/v1/illust/detail"`). */
|
|
397
|
+
endpoint: string;
|
|
398
|
+
/** HTTP status code returned by the endpoint. */
|
|
399
|
+
statusCode: number;
|
|
400
|
+
/** Number of recorded responses for this (method, endpoint, statusCode) combination. */
|
|
401
|
+
count: number;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Returns all unique (method, endpoint, statusCode) combinations seen in the
|
|
405
|
+
* last 90 days, along with the count of matching records.
|
|
406
|
+
*
|
|
407
|
+
* @param db - Drizzle ORM database instance
|
|
408
|
+
* @returns Endpoints sorted by count descending
|
|
409
|
+
*/
|
|
410
|
+
declare function getEndpoints(db: DbInstance): Promise<EndpointWithCount[]>;
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Schema bootstrapping for @book000/pixivts-db-mysql.
|
|
414
|
+
*
|
|
415
|
+
* Provides a `CREATE TABLE IF NOT EXISTS` helper that can be used at startup
|
|
416
|
+
* without requiring drizzle-kit to be installed in production.
|
|
417
|
+
*
|
|
418
|
+
* For full migrations, use:
|
|
419
|
+
* pnpm drizzle-kit generate
|
|
420
|
+
* pnpm drizzle-kit migrate
|
|
421
|
+
*/
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Creates the `responses` table if it does not already exist.
|
|
425
|
+
*
|
|
426
|
+
* This is a lightweight alternative to running drizzle-kit migrations in
|
|
427
|
+
* environments where the table has not been set up yet.
|
|
428
|
+
*
|
|
429
|
+
* @param db - Drizzle ORM database instance
|
|
430
|
+
*/
|
|
431
|
+
declare function bootstrapSchema(db: DbInstance): Promise<void>;
|
|
432
|
+
|
|
433
|
+
export { type ConnectionOptions, type DbInstance, type EndpointWithCount, type NewResponse, type RangeOptions, type RecorderBundle, type RecorderOptions, type ResponseFilter, type ResponseRow, addResponse, bootstrapSchema, createDbConnection, createRecorderBundle, createResponseRecorder, getEndpoints, getResponseCount, getResponses, responsesTable };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/mysql2';
|
|
2
|
+
import mysql from 'mysql2/promise';
|
|
3
|
+
import { mysqlTable, datetime, longtext, int, varchar, text, uniqueIndex } from 'drizzle-orm/mysql-core';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import { sql, and, gte, eq, desc, count } from 'drizzle-orm';
|
|
6
|
+
|
|
7
|
+
var __defProp = Object.defineProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/schema.ts
|
|
14
|
+
var schema_exports = {};
|
|
15
|
+
__export(schema_exports, {
|
|
16
|
+
responsesTable: () => responsesTable
|
|
17
|
+
});
|
|
18
|
+
var responsesTable = mysqlTable(
|
|
19
|
+
"responses",
|
|
20
|
+
{
|
|
21
|
+
/** Auto-increment primary key. */
|
|
22
|
+
id: int("id").autoincrement().primaryKey(),
|
|
23
|
+
/** HTTP method (GET or POST). */
|
|
24
|
+
method: varchar("method", { length: 10 }).notNull(),
|
|
25
|
+
/** API endpoint path (e.g. /v1/illust/detail). */
|
|
26
|
+
endpoint: varchar("endpoint", { length: 255 }).notNull(),
|
|
27
|
+
/** Full request URL (may be null for internal requests). */
|
|
28
|
+
url: text("url"),
|
|
29
|
+
/** SHA-256 hash of the request URL for deduplication. */
|
|
30
|
+
urlHash: varchar("url_hash", { length: 255 }).notNull(),
|
|
31
|
+
/** Serialised request headers (JSON string). */
|
|
32
|
+
requestHeaders: longtext("request_headers"),
|
|
33
|
+
/** Request body (URL-encoded string for POST, null for GET). */
|
|
34
|
+
requestBody: longtext("request_body"),
|
|
35
|
+
/** Response content type ("JSON" or "TEXT"). */
|
|
36
|
+
responseType: varchar("response_type", { length: 10 }).notNull(),
|
|
37
|
+
/** HTTP response status code. */
|
|
38
|
+
statusCode: int("status_code").notNull(),
|
|
39
|
+
/** Serialised response headers (JSON string). */
|
|
40
|
+
responseHeaders: longtext("response_headers"),
|
|
41
|
+
/** Raw response body. */
|
|
42
|
+
responseBody: longtext("response_body").notNull(),
|
|
43
|
+
/** Timestamp when the record was created. */
|
|
44
|
+
createdAt: datetime("created_at", { mode: "date", fsp: 3 }).notNull()
|
|
45
|
+
},
|
|
46
|
+
(table) => [
|
|
47
|
+
uniqueIndex("idx_unique").on(
|
|
48
|
+
table.method,
|
|
49
|
+
table.endpoint,
|
|
50
|
+
table.statusCode,
|
|
51
|
+
table.urlHash
|
|
52
|
+
)
|
|
53
|
+
]
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// src/connection.ts
|
|
57
|
+
function parsePort(value) {
|
|
58
|
+
if (!value) return 3306;
|
|
59
|
+
const parsed = Number.parseInt(value, 10);
|
|
60
|
+
return Number.isNaN(parsed) ? 3306 : parsed;
|
|
61
|
+
}
|
|
62
|
+
function createDbConnection(opts) {
|
|
63
|
+
const pool = mysql.createPool({
|
|
64
|
+
host: opts.host ?? process.env.RESPONSE_DB_HOSTNAME ?? "localhost",
|
|
65
|
+
port: opts.port ?? parsePort(process.env.RESPONSE_DB_PORT),
|
|
66
|
+
user: opts.user ?? process.env.RESPONSE_DB_USERNAME,
|
|
67
|
+
password: opts.password ?? process.env.RESPONSE_DB_PASSWORD,
|
|
68
|
+
database: opts.database ?? process.env.RESPONSE_DB_DATABASE,
|
|
69
|
+
timezone: "+09:00",
|
|
70
|
+
supportBigNumbers: true,
|
|
71
|
+
bigNumberStrings: true
|
|
72
|
+
});
|
|
73
|
+
const db = drizzle(pool, { schema: schema_exports, mode: "default" });
|
|
74
|
+
return { pool, db };
|
|
75
|
+
}
|
|
76
|
+
async function bootstrapSchema(db) {
|
|
77
|
+
await db.execute(sql`
|
|
78
|
+
CREATE TABLE IF NOT EXISTS responses (
|
|
79
|
+
id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'Response ID',
|
|
80
|
+
method VARCHAR(10) NOT NULL COMMENT 'HTTP method',
|
|
81
|
+
endpoint VARCHAR(255) NOT NULL COMMENT 'API endpoint path',
|
|
82
|
+
url TEXT NULL COMMENT 'Full request URL',
|
|
83
|
+
url_hash VARCHAR(255) NOT NULL COMMENT 'SHA-256 hash of the request URL',
|
|
84
|
+
request_headers LONGTEXT NULL COMMENT 'Request headers (JSON)',
|
|
85
|
+
request_body LONGTEXT NULL COMMENT 'Request body',
|
|
86
|
+
response_type VARCHAR(10) NOT NULL COMMENT 'Response content type',
|
|
87
|
+
status_code INT NOT NULL COMMENT 'HTTP status code',
|
|
88
|
+
response_headers LONGTEXT NULL COMMENT 'Response headers (JSON)',
|
|
89
|
+
response_body LONGTEXT NOT NULL COMMENT 'Response body',
|
|
90
|
+
created_at DATETIME(3) NOT NULL COMMENT 'Record creation timestamp',
|
|
91
|
+
UNIQUE INDEX idx_unique (method, endpoint, status_code, url_hash)
|
|
92
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
93
|
+
`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/recorder.ts
|
|
97
|
+
function ninetyDaysAgo() {
|
|
98
|
+
return new Date(Date.now() - 90 * 24 * 60 * 60 * 1e3);
|
|
99
|
+
}
|
|
100
|
+
async function addResponse(db, record) {
|
|
101
|
+
const urlToHash = record.url ?? `${record.method}:${record.endpoint}`;
|
|
102
|
+
const urlHash = crypto.createHash("sha256").update(urlToHash).digest("hex");
|
|
103
|
+
await db.insert(responsesTable).values({
|
|
104
|
+
method: record.method,
|
|
105
|
+
endpoint: record.endpoint,
|
|
106
|
+
url: record.url,
|
|
107
|
+
urlHash,
|
|
108
|
+
requestHeaders: record.requestHeaders,
|
|
109
|
+
requestBody: record.requestBody,
|
|
110
|
+
responseType: record.responseType,
|
|
111
|
+
statusCode: record.statusCode,
|
|
112
|
+
responseHeaders: record.responseHeaders,
|
|
113
|
+
responseBody: record.responseBody,
|
|
114
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
115
|
+
}).onDuplicateKeyUpdate({ set: { id: sql`id` } });
|
|
116
|
+
}
|
|
117
|
+
function createRecorderBundle(db, close) {
|
|
118
|
+
const interceptor = (record) => addResponse(db, record);
|
|
119
|
+
return { interceptor, db, close };
|
|
120
|
+
}
|
|
121
|
+
async function createResponseRecorder(opts) {
|
|
122
|
+
const { pool, db } = createDbConnection(opts);
|
|
123
|
+
if (opts.bootstrap) {
|
|
124
|
+
await bootstrapSchema(db);
|
|
125
|
+
}
|
|
126
|
+
return createRecorderBundle(db, () => pool.end());
|
|
127
|
+
}
|
|
128
|
+
async function getResponses(db, filter, range) {
|
|
129
|
+
const since = ninetyDaysAgo();
|
|
130
|
+
const limit = range?.limit ?? 100;
|
|
131
|
+
const offset = ((range?.page ?? 1) - 1) * limit;
|
|
132
|
+
return db.select().from(responsesTable).where(
|
|
133
|
+
and(
|
|
134
|
+
gte(responsesTable.createdAt, since),
|
|
135
|
+
filter?.method ? eq(responsesTable.method, filter.method) : void 0,
|
|
136
|
+
filter?.endpoint ? eq(responsesTable.endpoint, filter.endpoint) : void 0,
|
|
137
|
+
filter?.statusCode ? eq(responsesTable.statusCode, filter.statusCode) : void 0
|
|
138
|
+
)
|
|
139
|
+
).orderBy(desc(responsesTable.createdAt)).limit(limit).offset(offset);
|
|
140
|
+
}
|
|
141
|
+
async function getResponseCount(db, filter) {
|
|
142
|
+
const since = ninetyDaysAgo();
|
|
143
|
+
const rows = await db.select({ value: count() }).from(responsesTable).where(
|
|
144
|
+
and(
|
|
145
|
+
gte(responsesTable.createdAt, since),
|
|
146
|
+
filter?.method ? eq(responsesTable.method, filter.method) : void 0,
|
|
147
|
+
filter?.endpoint ? eq(responsesTable.endpoint, filter.endpoint) : void 0,
|
|
148
|
+
filter?.statusCode ? eq(responsesTable.statusCode, filter.statusCode) : void 0
|
|
149
|
+
)
|
|
150
|
+
);
|
|
151
|
+
return rows[0]?.value ?? 0;
|
|
152
|
+
}
|
|
153
|
+
async function getEndpoints(db) {
|
|
154
|
+
const since = ninetyDaysAgo();
|
|
155
|
+
const rows = await db.select({
|
|
156
|
+
method: responsesTable.method,
|
|
157
|
+
endpoint: responsesTable.endpoint,
|
|
158
|
+
statusCode: responsesTable.statusCode,
|
|
159
|
+
count: count()
|
|
160
|
+
}).from(responsesTable).where(gte(responsesTable.createdAt, since)).groupBy(
|
|
161
|
+
responsesTable.method,
|
|
162
|
+
responsesTable.endpoint,
|
|
163
|
+
responsesTable.statusCode
|
|
164
|
+
).orderBy(desc(count()));
|
|
165
|
+
return rows.map((r) => ({ ...r, count: r.count }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export { addResponse, bootstrapSchema, createDbConnection, createRecorderBundle, createResponseRecorder, getEndpoints, getResponseCount, getResponses, responsesTable };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@book000/pixivts-db-mysql",
|
|
3
|
-
"version": "0.56.
|
|
3
|
+
"version": "0.56.2",
|
|
4
4
|
"description": "MySQL response recorder for @book000/pixivts (Drizzle ORM)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pixiv",
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
"url": "git+https://github.com/book000/pixivts.git",
|
|
20
20
|
"directory": "packages/db-mysql"
|
|
21
21
|
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
22
25
|
"type": "module",
|
|
23
26
|
"files": [
|
|
24
27
|
"dist"
|
|
@@ -43,7 +46,7 @@
|
|
|
43
46
|
"mysql2": "3.22.5"
|
|
44
47
|
},
|
|
45
48
|
"peerDependencies": {
|
|
46
|
-
"@book000/pixivts": "
|
|
49
|
+
"@book000/pixivts": "0.56.2"
|
|
47
50
|
},
|
|
48
51
|
"devDependencies": {
|
|
49
52
|
"@types/node": "24.13.2",
|
|
@@ -56,8 +59,8 @@
|
|
|
56
59
|
"scripts": {
|
|
57
60
|
"build": "tsup",
|
|
58
61
|
"clean": "rimraf dist",
|
|
59
|
-
"lint": "tsc
|
|
62
|
+
"lint": "tsc -p tsconfig.test.json",
|
|
60
63
|
"fix": "echo 'no fix needed'",
|
|
61
64
|
"test:integration": "vitest run --config vitest.integration.config.ts"
|
|
62
65
|
}
|
|
63
|
-
}
|
|
66
|
+
}
|