@zhin.js/database 1.1.7 → 1.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ Universal database abstraction layer for Zhin.js framework with support for mult
8
8
  npm install @zhin.js/database
9
9
  ```
10
10
 
11
- Install the database driver you need(SQLite 使用 Node 内置 `node:sqlite`,无需安装,需 Node.js 22.5+,推荐 24+):
11
+ Install the database driver you need(SQLite 使用 Node 内置 `node:sqlite`,无需安装,需 Node.js >=22.19.0,推荐 24+):
12
12
 
13
13
  ```bash
14
14
  # For MySQL
@@ -83,7 +83,7 @@ const user = await userModel.create({
83
83
  ## Supported Databases
84
84
 
85
85
  ### ✅ Relational Databases (已完整实现)
86
- - **SQLite** - 内置支持,使用 Node 自带 `node:sqlite`(无需安装驱动,需 Node.js 22.5+,推荐 24+)
86
+ - **SQLite** - 内置支持,使用 Node 自带 `node:sqlite`(无需安装驱动,需 Node.js >=22.19.0,推荐 24+)
87
87
  - 轻量级、零配置
88
88
  - 适合中小型应用
89
89
  - 支持 WAL 模式
@@ -262,27 +262,33 @@ await db.start();
262
262
 
263
263
  ## Model API
264
264
 
265
+ 以下为关系型模型(`RelatedModel`,SQLite / MySQL / PostgreSQL)的便捷方法;更复杂的查询见下方链式查询 API。
266
+
265
267
  ```typescript
266
268
  const model = db.model('users');
267
269
 
268
270
  // Create
269
271
  const user = await model.create({ name: 'John', email: 'john@example.com' });
272
+ const created = await model.createMany([{ name: 'A' }, { name: 'B' }]);
270
273
 
271
274
  // Read
272
- const users = await model.find({ name: 'John' });
273
- const user = await model.findOne({ email: 'john@example.com' });
275
+ const users = await model.findAll({ name: 'John' });
276
+ const one = await model.findOne({ email: 'john@example.com' });
277
+ const byId = await model.findById(1);
274
278
 
275
- // Update
276
- await model.update({ name: 'John' }, { name: 'Jane' });
279
+ // Update —— 条件在前、数据在后;返回受影响行数
280
+ await model.updateWhere({ name: 'John' }, { name: 'Jane' });
281
+ await model.updateById(1, { name: 'Jane' });
277
282
 
278
- // Delete
279
- await model.remove({ name: 'Jane' });
283
+ // Delete —— 启用软删除的模型会改为标记 deletedAt
284
+ await model.deleteWhere({ name: 'Jane' });
285
+ await model.deleteById(1);
280
286
 
281
287
  // Count
282
288
  const count = await model.count({ email: { $like: '%@example.com' } });
283
289
 
284
- // Pagination
285
- const result = await model.find({}, { limit: 10, offset: 0 });
290
+ // Pagination —— 用链式查询
291
+ const page = await model.select().where({}).limit(10).offset(0);
286
292
  ```
287
293
 
288
294
  ## 链式查询 API (Fluent Query Builder)
@@ -4,10 +4,30 @@ import { RelatedDatabase } from '../type/related/database.js';
4
4
  export interface SQLiteDialectConfig {
5
5
  filename: string;
6
6
  mode?: string;
7
+ /**
8
+ * `PRAGMA busy_timeout` in ms (default 0: no synchronous wait; see `busyRetryMs`). The wait
9
+ * is synchronous (`node:sqlite`), so only a connection whose own writes are short statements
10
+ * should set it — the gateway does for its `console_*` connection (ADR 0054 §7). Two
11
+ * connections of one process with async transactions would block each other's event loop.
12
+ */
13
+ busyTimeoutMs?: number;
14
+ /**
15
+ * Without `busyTimeoutMs`, a statement outside a transaction (and `BEGIN IMMEDIATE`) that hits
16
+ * `SQLITE_BUSY` is retried with an async backoff for at most this long (default 5000 ms; 0
17
+ * disables). The event loop keeps running while another process (the gateway) holds the
18
+ * write lock, so two connections in one process cannot deadlock each other.
19
+ */
20
+ busyRetryMs?: number;
7
21
  }
22
+ /** Default budget of {@link SQLiteDialectConfig.busyRetryMs}. */
23
+ export declare const SQLITE_BUSY_RETRY_MS = 5000;
24
+ /** `SQLITE_BUSY` and its extended codes except `SQLITE_BUSY_SNAPSHOT` (a stale read snapshot never clears by waiting). */
25
+ export declare function isSqliteBusyError(error: unknown): boolean;
8
26
  export declare class SQLiteDialect<S extends Record<string, object> = Record<string, object>> extends Dialect<SQLiteDialectConfig, S, string> {
9
27
  /** Node 内置 SQLite 连接(node:sqlite),在 connect() 中动态加载 */
10
28
  private db;
29
+ /** Own transaction flag; `DatabaseSync#isTransaction` is preferred where Node provides it. */
30
+ private transactionOpen;
11
31
  constructor(config: SQLiteDialectConfig);
12
32
  isConnected(): boolean;
13
33
  connect(): Promise<void>;
@@ -16,6 +36,10 @@ export declare class SQLiteDialect<S extends Record<string, object> = Record<str
16
36
  /** node:sqlite 仅支持 number/string/bigint/buffer/null;JSON 列在库内为 TEXT,对象需序列化 */
17
37
  private prepareBindParams;
18
38
  query<U = any>(sql: string, params?: any[]): Promise<U>;
39
+ /** Async retry budget: only for connections that do not wait synchronously (`busyTimeoutMs`). */
40
+ private busyRetryBudget;
41
+ private inTransaction;
42
+ private execute;
19
43
  /** Column declarations are scoped to the table in this database, never the result alias. */
20
44
  private readonly jsonColumns;
21
45
  registerTableSchema(table: string, definition: Record<string, {
@@ -1,10 +1,27 @@
1
1
  import * as fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { setTimeout as delay } from 'node:timers/promises';
3
4
  import { Dialect } from '../base/index.js';
4
5
  import { RelatedDatabase } from '../type/related/database.js';
6
+ /** Default budget of {@link SQLiteDialectConfig.busyRetryMs}. */
7
+ export const SQLITE_BUSY_RETRY_MS = 5_000;
8
+ const BUSY_RETRY_FIRST_DELAY_MS = 2;
9
+ const BUSY_RETRY_MAX_DELAY_MS = 100;
10
+ /** `SQLITE_BUSY` and its extended codes except `SQLITE_BUSY_SNAPSHOT` (a stale read snapshot never clears by waiting). */
11
+ export function isSqliteBusyError(error) {
12
+ if (!error || typeof error !== 'object')
13
+ return false;
14
+ const errcode = error.errcode;
15
+ return typeof errcode === 'number' && (errcode & 0xff) === 5 && errcode !== 517;
16
+ }
17
+ function isInMemoryFilename(filename) {
18
+ return filename === ':memory:' || filename === '' || filename.startsWith('file::memory:');
19
+ }
5
20
  export class SQLiteDialect extends Dialect {
6
21
  /** Node 内置 SQLite 连接(node:sqlite),在 connect() 中动态加载 */
7
22
  db = null;
23
+ /** Own transaction flag; `DatabaseSync#isTransaction` is preferred where Node provides it. */
24
+ transactionOpen = false;
8
25
  constructor(config) {
9
26
  super('sqlite', config);
10
27
  }
@@ -20,7 +37,21 @@ export class SQLiteDialect extends Dialect {
20
37
  if (!fs.existsSync(dirname)) {
21
38
  fs.mkdirSync(dirname, { recursive: true });
22
39
  }
23
- this.db = new DatabaseSync(this.config.filename);
40
+ const db = new DatabaseSync(this.config.filename);
41
+ try {
42
+ // Two processes share the file (gateway: console_* tables, instance: the rest):
43
+ // WAL lets readers run beside one writer and never blocks readers on a writer.
44
+ const busy = this.config.busyTimeoutMs;
45
+ if (busy !== undefined && Number.isSafeInteger(busy) && busy > 0)
46
+ db.exec(`PRAGMA busy_timeout = ${busy}`);
47
+ if (!isInMemoryFilename(this.config.filename))
48
+ db.exec('PRAGMA journal_mode = WAL');
49
+ }
50
+ catch (error) {
51
+ db.close();
52
+ throw error;
53
+ }
54
+ this.db = db;
24
55
  }
25
56
  catch (error) {
26
57
  throw new Error(`SQLite 连接失败(需要 Node.js 22.5+,推荐 24+): ${error}`);
@@ -58,6 +89,42 @@ export class SQLiteDialect extends Dialect {
58
89
  });
59
90
  }
60
91
  async query(sql, params) {
92
+ const budget = this.busyRetryBudget();
93
+ if (budget <= 0)
94
+ return this.execute(sql, params);
95
+ const deadline = Date.now() + budget;
96
+ let wait = BUSY_RETRY_FIRST_DELAY_MS;
97
+ for (;;) {
98
+ // A statement inside an open transaction is never replayed here: the transaction's caller
99
+ // owns that retry (BEGIN IMMEDIATE takes the write lock up front, so this is rare).
100
+ // COMMIT is the exception: SQLite documents a busy COMMIT as safe to retry.
101
+ const replayable = !this.inTransaction() || /^\s*commit\b/i.test(sql);
102
+ try {
103
+ return await this.execute(sql, params);
104
+ }
105
+ catch (error) {
106
+ if (!replayable || !isSqliteBusyError(error) || Date.now() + wait > deadline)
107
+ throw error;
108
+ }
109
+ await delay(wait);
110
+ wait = Math.min(wait * 2, BUSY_RETRY_MAX_DELAY_MS);
111
+ }
112
+ }
113
+ /** Async retry budget: only for connections that do not wait synchronously (`busyTimeoutMs`). */
114
+ busyRetryBudget() {
115
+ const busy = this.config.busyTimeoutMs;
116
+ if (busy !== undefined && busy > 0)
117
+ return 0;
118
+ if (isInMemoryFilename(this.config.filename))
119
+ return 0;
120
+ const budget = this.config.busyRetryMs ?? SQLITE_BUSY_RETRY_MS;
121
+ return Number.isFinite(budget) && budget > 0 ? budget : 0;
122
+ }
123
+ inTransaction() {
124
+ const native = this.db?.isTransaction;
125
+ return typeof native === 'boolean' ? native : this.transactionOpen;
126
+ }
127
+ async execute(sql, params) {
61
128
  if (!this.db)
62
129
  throw new Error('SQLite 未连接');
63
130
  const trimmedSql = sql.trim().toLowerCase();
@@ -235,15 +302,44 @@ export class SQLiteDialect extends Dialect {
235
302
  */
236
303
  async beginTransaction(options) {
237
304
  const dialect = this;
238
- // 开始事务
239
- await this.query('BEGIN TRANSACTION');
305
+ const readOnly = options?.readOnly === true;
306
+ // Write transactions: IMMEDIATE takes the write lock at BEGIN, so contention with the
307
+ // gateway's connection surfaces there (retried by query()), never as SQLITE_BUSY_SNAPSHOT
308
+ // halfway through. A declared read-only transaction takes no write lock (DEFERRED: WAL
309
+ // readers never wait for a writer) and query_only makes an accidental write fail loudly.
310
+ // Callers that cannot declare intent (the plugin-runtime Database Host) stay IMMEDIATE.
311
+ await this.query(readOnly ? 'BEGIN DEFERRED' : 'BEGIN IMMEDIATE');
312
+ this.transactionOpen = true;
313
+ if (readOnly) {
314
+ try {
315
+ await this.query('PRAGMA query_only = ON');
316
+ }
317
+ catch (error) {
318
+ await this.query('ROLLBACK').catch(() => undefined);
319
+ this.transactionOpen = false;
320
+ throw error;
321
+ }
322
+ }
323
+ const close = async () => {
324
+ dialect.transactionOpen = false;
325
+ if (readOnly)
326
+ await dialect.query('PRAGMA query_only = OFF').catch(() => undefined);
327
+ };
328
+ const finish = async (sql) => {
329
+ try {
330
+ await dialect.query(sql);
331
+ }
332
+ catch (error) {
333
+ // A failed COMMIT (e.g. busy past the budget) leaves the transaction to the caller's rollback.
334
+ if (sql === 'ROLLBACK')
335
+ await close();
336
+ throw error;
337
+ }
338
+ await close();
339
+ };
240
340
  return {
241
- async commit() {
242
- await dialect.query('COMMIT');
243
- },
244
- async rollback() {
245
- await dialect.query('ROLLBACK');
246
- },
341
+ commit: () => finish('COMMIT'),
342
+ rollback: () => finish('ROLLBACK'),
247
343
  async query(sql, params) {
248
344
  return dialect.query(sql, params);
249
345
  }
package/lib/index.d.ts CHANGED
@@ -13,3 +13,5 @@ export * from './dialects/memory.js';
13
13
  export * from './dialects/sqlite.js';
14
14
  export * from './migration.js';
15
15
  export * from './structured-schema.js';
16
+ export * from './probe.js';
17
+ export * from './reserved-tables.js';
package/lib/index.js CHANGED
@@ -13,5 +13,7 @@ export * from './dialects/memory.js';
13
13
  export * from './dialects/sqlite.js';
14
14
  export * from './migration.js';
15
15
  export * from './structured-schema.js';
16
+ export * from './probe.js';
17
+ export * from './reserved-tables.js';
16
18
  import { registerBuiltinDatabases } from './builtin-runtime.js';
17
19
  registerBuiltinDatabases();
package/lib/probe.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * One-shot connection probe for setup wizards: connect, run the dialect health query, disconnect.
3
+ * Error text is stripped of credentials before it is returned.
4
+ */
5
+ export type ProbeDialect = 'sqlite' | 'mysql' | 'pg' | 'mongodb';
6
+ export interface DatabaseProbeResult {
7
+ readonly ok: boolean;
8
+ readonly latencyMs?: number;
9
+ readonly error?: string;
10
+ }
11
+ interface ProbeTarget {
12
+ connect(): Promise<void>;
13
+ disconnect(): Promise<void>;
14
+ healthCheck(): Promise<boolean>;
15
+ }
16
+ /** Driver package that must be importable for each dialect (`null`: built into Node). */
17
+ export declare const DATABASE_DRIVER_PACKAGES: Readonly<Record<ProbeDialect, string | null>>;
18
+ /** Whether the optional driver package for `dialect` can be loaded. */
19
+ export declare function isDatabaseDriverAvailable(dialect: ProbeDialect, importer?: (specifier: string) => Promise<unknown>): Promise<boolean>;
20
+ /**
21
+ * Opens a temporary connection with `config` and closes it again.
22
+ * `factory` is a test seam; it defaults to the built-in dialects.
23
+ */
24
+ export declare function probeDatabaseConnection(dialect: ProbeDialect, config: Record<string, unknown>, options?: Readonly<{
25
+ timeoutMs?: number;
26
+ factory?: (dialect: ProbeDialect, config: Record<string, unknown>) => Promise<ProbeTarget>;
27
+ }>): Promise<DatabaseProbeResult>;
28
+ /** Removes credentials (configured secret values, URL userinfo) from a driver error. */
29
+ export declare function redactProbeError(error: unknown, secrets?: readonly string[]): string;
30
+ export {};
package/lib/probe.js ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * One-shot connection probe for setup wizards: connect, run the dialect health query, disconnect.
3
+ * Error text is stripped of credentials before it is returned.
4
+ */
5
+ /** Driver package that must be importable for each dialect (`null`: built into Node). */
6
+ export const DATABASE_DRIVER_PACKAGES = Object.freeze({
7
+ sqlite: null,
8
+ mysql: 'mysql2',
9
+ pg: 'pg',
10
+ mongodb: 'mongodb',
11
+ });
12
+ /** Whether the optional driver package for `dialect` can be loaded. */
13
+ export async function isDatabaseDriverAvailable(dialect, importer = specifier => import(specifier)) {
14
+ const pkg = DATABASE_DRIVER_PACKAGES[dialect];
15
+ if (pkg === null)
16
+ return true;
17
+ try {
18
+ await importer(pkg);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ async function createProbeTarget(dialect, config) {
26
+ switch (dialect) {
27
+ case 'sqlite': {
28
+ const { SQLiteDialect } = await import('./dialects/sqlite.js');
29
+ return new SQLiteDialect(config);
30
+ }
31
+ case 'mysql': {
32
+ const { MySQLDialect } = await import('./dialects/mysql.js');
33
+ return new MySQLDialect(config);
34
+ }
35
+ case 'pg': {
36
+ const { PostgreSQLDialect } = await import('./dialects/pg.js');
37
+ return new PostgreSQLDialect(config);
38
+ }
39
+ case 'mongodb': {
40
+ const { MongoDBDialect } = await import('./dialects/mongodb.js');
41
+ return new MongoDBDialect(config);
42
+ }
43
+ default:
44
+ throw new Error(`Unsupported dialect: ${String(dialect)}`);
45
+ }
46
+ }
47
+ /**
48
+ * Opens a temporary connection with `config` and closes it again.
49
+ * `factory` is a test seam; it defaults to the built-in dialects.
50
+ */
51
+ export async function probeDatabaseConnection(dialect, config, options = {}) {
52
+ const secrets = collectSecrets(config);
53
+ const startedAt = Date.now();
54
+ let target;
55
+ let timer;
56
+ const timeout = new Promise((_resolve, reject) => {
57
+ timer = setTimeout(() => reject(new Error('connection timed out')), options.timeoutMs ?? 10_000);
58
+ });
59
+ try {
60
+ target = await (options.factory ?? createProbeTarget)(dialect, config);
61
+ await Promise.race([target.connect(), timeout]);
62
+ const healthy = await Promise.race([target.healthCheck(), timeout]);
63
+ if (!healthy)
64
+ return Object.freeze({ ok: false, error: 'probe query failed' });
65
+ return Object.freeze({ ok: true, latencyMs: Date.now() - startedAt });
66
+ }
67
+ catch (error) {
68
+ return Object.freeze({ ok: false, error: redactProbeError(error, secrets) });
69
+ }
70
+ finally {
71
+ clearTimeout(timer);
72
+ await target?.disconnect().catch(() => undefined);
73
+ }
74
+ }
75
+ function collectSecrets(config) {
76
+ const values = [];
77
+ for (const [key, value] of Object.entries(config)) {
78
+ if (typeof value !== 'string' || !value)
79
+ continue;
80
+ if (/pass|secret|token|key/iu.test(key))
81
+ values.push(value);
82
+ if (/url|uri|connectionString/iu.test(key)) {
83
+ const credentials = /\/\/([^@/]*)@/u.exec(value)?.[1];
84
+ if (credentials)
85
+ values.push(credentials, ...credentials.split(':').filter(Boolean));
86
+ }
87
+ }
88
+ return values.filter(value => value.length >= 3).sort((a, b) => b.length - a.length);
89
+ }
90
+ /** Removes credentials (configured secret values, URL userinfo) from a driver error. */
91
+ export function redactProbeError(error, secrets = []) {
92
+ let message = error instanceof Error ? error.message : String(error);
93
+ for (const secret of secrets)
94
+ message = message.split(secret).join('***');
95
+ message = message.replace(/\/\/[^@\s/]*@/gu, '//***@');
96
+ message = message.replace(/(password|pwd)\s*[=:]\s*\S+/giu, '$1=***');
97
+ return message.slice(0, 300);
98
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Tables owned by the management gateway (ADR 0049 / 0054 §7). The gateway and the instance share
3
+ * one project database; the gateway's tables hold password hashes, session / token hashes and the
4
+ * audit log, so every instance-side path (plugin models, Console database page, raw SQL) must deny
5
+ * them. Deny by prefix so future gateway tables are covered without another list to update.
6
+ */
7
+ export declare const GATEWAY_TABLE_PREFIX = "console_";
8
+ /** `console_`-prefixed tables that the instance itself owns (historical names). */
9
+ export declare const INSTANCE_CONSOLE_TABLES: readonly string[];
10
+ /** True when `name` is a gateway-owned table (case-insensitive, quotes and schema ignored). */
11
+ export declare function isGatewayOwnedTable(name: string): boolean;
12
+ /** True when SQL text mentions a gateway-owned table anywhere (identifiers, quoted or literal). */
13
+ export declare function referencesGatewayOwnedTable(sql: string): boolean;
14
+ export declare class GatewayOwnedTableError extends Error {
15
+ constructor(table?: string);
16
+ }
17
+ /** Throws {@link GatewayOwnedTableError} for a gateway-owned table name. */
18
+ export declare function assertInstanceTable(name: string): void;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Tables owned by the management gateway (ADR 0049 / 0054 §7). The gateway and the instance share
3
+ * one project database; the gateway's tables hold password hashes, session / token hashes and the
4
+ * audit log, so every instance-side path (plugin models, Console database page, raw SQL) must deny
5
+ * them. Deny by prefix so future gateway tables are covered without another list to update.
6
+ */
7
+ export const GATEWAY_TABLE_PREFIX = 'console_';
8
+ /** `console_`-prefixed tables that the instance itself owns (historical names). */
9
+ export const INSTANCE_CONSOLE_TABLES = Object.freeze(['console_conversations']);
10
+ const instanceConsoleTables = new Set(INSTANCE_CONSOLE_TABLES);
11
+ /** True when `name` is a gateway-owned table (case-insensitive, quotes and schema ignored). */
12
+ export function isGatewayOwnedTable(name) {
13
+ const bare = String(name).trim().replace(/^[`"[]|[`"\]]$/g, '');
14
+ const table = (bare.includes('.') ? bare.slice(bare.lastIndexOf('.') + 1) : bare)
15
+ .replace(/^[`"[]|[`"\]]$/g, '').toLowerCase();
16
+ return table.startsWith(GATEWAY_TABLE_PREFIX) && !instanceConsoleTables.has(table);
17
+ }
18
+ const IDENTIFIER = /(?<![A-Za-z0-9_$])console_[A-Za-z0-9_$]*/gi;
19
+ /** True when SQL text mentions a gateway-owned table anywhere (identifiers, quoted or literal). */
20
+ export function referencesGatewayOwnedTable(sql) {
21
+ for (const match of String(sql).matchAll(IDENTIFIER)) {
22
+ if (isGatewayOwnedTable(match[0]))
23
+ return true;
24
+ }
25
+ return false;
26
+ }
27
+ export class GatewayOwnedTableError extends Error {
28
+ constructor(table) {
29
+ super(table
30
+ ? `Table '${table}' is owned by the management gateway and is not accessible from the instance`
31
+ : 'Statement references a table owned by the management gateway');
32
+ this.name = 'GatewayOwnedTableError';
33
+ }
34
+ }
35
+ /** Throws {@link GatewayOwnedTableError} for a gateway-owned table name. */
36
+ export function assertInstanceTable(name) {
37
+ if (isGatewayOwnedTable(name))
38
+ throw new GatewayOwnedTableError(name);
39
+ }
package/lib/types.d.ts CHANGED
@@ -285,6 +285,11 @@ export type IsolationLevel = 'READ_UNCOMMITTED' | 'READ_COMMITTED' | 'REPEATABLE
285
285
  export interface TransactionOptions {
286
286
  isolationLevel?: IsolationLevel;
287
287
  timeout?: number;
288
+ /**
289
+ * The transaction only reads. SQLite then opens it with `BEGIN DEFERRED` (no write lock, so it
290
+ * never waits for another connection's writer) and refuses writes inside it (`query_only`).
291
+ */
292
+ readOnly?: boolean;
288
293
  }
289
294
  export interface Transaction {
290
295
  commit(): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/database",
3
- "version": "1.1.7",
3
+ "version": "1.1.8",
4
4
  "description": "Universal database abstraction layer for Zhin.js - supports SQLite, MySQL, PostgreSQL, MongoDB, Redis",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -43,14 +43,15 @@
43
43
  "engines": {
44
44
  "node": ">=22.19.0"
45
45
  },
46
+ "license": "MIT",
46
47
  "dependencies": {
47
- "@zhin.js/logger": "1.1.2"
48
+ "@zhin.js/logger": "1.1.3"
48
49
  },
49
50
  "peerDependencies": {
50
51
  "mongodb": ">=3.1.13",
51
52
  "mysql2": ">=3.9.8",
52
53
  "pg": ">=2.11.2",
53
- "redis": "latest"
54
+ "redis": ">=4.0.0"
54
55
  },
55
56
  "peerDependenciesMeta": {
56
57
  "mysql2": {