@yunsoft/yuncms-core 0.1.3 → 0.1.6

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/src/query.js CHANGED
@@ -1,24 +1,46 @@
1
1
  import { quoteIdentifier } from './identifier.js';
2
2
 
3
- const QUERY_KEYS = new Set(['fields', 'filter', 'sort', 'limit', 'offset']);
3
+ const QUERY_KEYS = new Set(['fields', 'filter', 'sort', 'limit', 'offset', 'search', 'aggregate', 'groupBy']);
4
4
  const FILTER_OPERATORS = new Set([
5
5
  '_eq', '_neq', '_lt', '_lte', '_gt', '_gte',
6
6
  '_in', '_nin', '_null', '_nnull',
7
7
  '_contains', '_starts_with', '_ends_with',
8
8
  ]);
9
+ const AGGREGATE_FUNCTIONS = new Set(['count', 'countDistinct', 'sum', 'avg', 'min', 'max']);
10
+ const SEARCHABLE_TYPES = new Set(['string', 'text']);
9
11
 
10
- function queryError(message, path = null) {
12
+ export const QUERY_LIMITS = Object.freeze({
13
+ defaultLimit: 100,
14
+ maxLimit: 500,
15
+ maxFields: 100,
16
+ maxRelationExpansions: 20,
17
+ maxRelationDepth: 4,
18
+ maxSortFields: 20,
19
+ maxOffset: 1_000_000,
20
+ maxFilterDepth: 8,
21
+ maxFilterNodes: 100,
22
+ maxInValues: 100,
23
+ maxSearchLength: 200,
24
+ maxAggregateFields: 20,
25
+ maxGroupByFields: 10,
26
+ maxCost: 2_000,
27
+ });
28
+
29
+ function queryError(message, path = null, code = 'INVALID_QUERY') {
11
30
  const error = new Error(message);
12
- error.code = 'INVALID_QUERY';
31
+ error.code = code;
13
32
  if (path) error.path = path;
14
33
  return error;
15
34
  }
16
35
 
17
- function normalizeDelimited(value, label) {
36
+ function normalizeDelimited(value, label, { maxItems }) {
18
37
  if (value == null || value === '') return null;
19
38
  const values = Array.isArray(value) ? value : String(value).split(',');
20
39
  const normalized = values.map((item) => String(item).trim()).filter(Boolean);
21
40
  if (normalized.length === 0) throw queryError(`${label} cannot be empty`, label);
41
+ if (normalized.length > maxItems) {
42
+ throw queryError(`${label} cannot contain more than ${maxItems} entries`, label);
43
+ }
22
44
  return normalized;
23
45
  }
24
46
 
@@ -36,36 +58,92 @@ function normalizeFilter(value) {
36
58
  if (typeof value === 'string') {
37
59
  try {
38
60
  const parsed = JSON.parse(value);
39
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
40
- throw new Error('not an object');
41
- }
61
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object');
42
62
  return parsed;
43
63
  } catch {
44
64
  throw queryError('filter must be a valid JSON object', 'filter');
45
65
  }
46
66
  }
47
- if (typeof value !== 'object' || Array.isArray(value)) {
48
- throw queryError('filter must be an object', 'filter');
49
- }
67
+ if (typeof value !== 'object' || Array.isArray(value)) throw queryError('filter must be an object', 'filter');
50
68
  return value;
51
69
  }
52
70
 
53
- export function parseItemsQuery(raw = {}, { defaultLimit = 100, maxLimit = 500 } = {}) {
54
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
55
- throw queryError('Query must be an object');
71
+ function normalizeSearch(value, limits) {
72
+ if (value == null || value === '') return null;
73
+ if (Array.isArray(value) || typeof value === 'object') throw queryError('search must be a string', 'search');
74
+ const search = String(value).trim();
75
+ if (!search) return null;
76
+ if (search.length > limits.maxSearchLength) {
77
+ throw queryError(`search cannot exceed ${limits.maxSearchLength} characters`, 'search');
56
78
  }
79
+ return search;
80
+ }
81
+
82
+ function normalizeAggregate(value, limits) {
83
+ if (value == null || value === '') return null;
84
+ let aggregate = value;
85
+ if (typeof aggregate === 'string') {
86
+ try { aggregate = JSON.parse(aggregate); } catch { throw queryError('aggregate must be a JSON object', 'aggregate'); }
87
+ }
88
+ if (!aggregate || typeof aggregate !== 'object' || Array.isArray(aggregate)) {
89
+ throw queryError('aggregate must be an object', 'aggregate');
90
+ }
91
+
92
+ const normalized = {};
93
+ let count = 0;
94
+ for (const [fn, rawFields] of Object.entries(aggregate)) {
95
+ if (!AGGREGATE_FUNCTIONS.has(fn)) throw queryError(`Unknown aggregate function: ${fn}`, `aggregate.${fn}`);
96
+ const fields = normalizeDelimited(rawFields, `aggregate.${fn}`, { maxItems: limits.maxAggregateFields });
97
+ if (!fields) throw queryError(`aggregate.${fn} cannot be empty`, `aggregate.${fn}`);
98
+ count += fields.length;
99
+ if (count > limits.maxAggregateFields) {
100
+ throw queryError(`aggregate cannot contain more than ${limits.maxAggregateFields} fields`, 'aggregate');
101
+ }
102
+ normalized[fn] = fields;
103
+ }
104
+ if (Object.keys(normalized).length === 0) throw queryError('aggregate cannot be empty', 'aggregate');
105
+ return normalized;
106
+ }
107
+
108
+ export function parseItemsQuery(raw = {}, options = {}) {
109
+ const limits = { ...QUERY_LIMITS, ...options };
110
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw queryError('Query must be an object');
57
111
 
58
112
  for (const key of Object.keys(raw)) {
59
113
  if (!QUERY_KEYS.has(key)) throw queryError(`Unknown query parameter: ${key}`, key);
60
114
  }
61
115
 
62
- return {
63
- fields: normalizeDelimited(raw.fields, 'fields'),
116
+ const query = {
117
+ fields: normalizeDelimited(raw.fields, 'fields', { maxItems: limits.maxFields }),
64
118
  filter: normalizeFilter(raw.filter),
65
- sort: normalizeDelimited(raw.sort, 'sort'),
66
- limit: normalizeInteger(raw.limit, defaultLimit, { label: 'limit', min: 1, max: maxLimit }),
67
- offset: normalizeInteger(raw.offset, 0, { label: 'offset', min: 0, max: Number.MAX_SAFE_INTEGER }),
119
+ sort: normalizeDelimited(raw.sort, 'sort', { maxItems: limits.maxSortFields }),
120
+ limit: normalizeInteger(raw.limit, limits.defaultLimit, { label: 'limit', min: 1, max: limits.maxLimit }),
121
+ offset: normalizeInteger(raw.offset, 0, { label: 'offset', min: 0, max: limits.maxOffset }),
122
+ search: normalizeSearch(raw.search, limits),
123
+ aggregate: normalizeAggregate(raw.aggregate, limits),
124
+ groupBy: normalizeDelimited(raw.groupBy, 'groupBy', { maxItems: limits.maxGroupByFields }),
68
125
  };
126
+
127
+ if (query.groupBy && !query.aggregate) throw queryError('groupBy requires aggregate', 'groupBy');
128
+ return Object.freeze(query);
129
+ }
130
+
131
+ export function queryCost(query, { relationDepth = 0, relationCount = 0 } = {}) {
132
+ let cost = 1 + Number(query?.limit ?? QUERY_LIMITS.defaultLimit);
133
+ cost += (query?.fields?.length ?? 0) * 2;
134
+ cost += (query?.sort?.length ?? 0) * 5;
135
+ cost += relationCount * 50;
136
+ cost += relationDepth * 100;
137
+ if (query?.search) cost += 100;
138
+ if (query?.aggregate) cost += 250 + Object.values(query.aggregate).flat().length * 25;
139
+ return cost;
140
+ }
141
+
142
+ export function assertQueryCost(query, options = {}) {
143
+ const maxCost = options.maxCost ?? QUERY_LIMITS.maxCost;
144
+ const cost = queryCost(query, options);
145
+ if (cost > maxCost) throw queryError(`Query cost ${cost} exceeds limit ${maxCost}`, null, 'QUERY_COST_LIMIT');
146
+ return cost;
69
147
  }
70
148
 
71
149
  function resolveField(schema, field, path = field) {
@@ -76,7 +154,6 @@ function resolveField(schema, field, path = field) {
76
154
  export function compileSelectFields(fields, schema) {
77
155
  const selected = !fields || fields.includes('*') ? Object.keys(schema.fields) : fields;
78
156
  if (selected.length === 0) throw queryError('At least one field must be selected', 'fields');
79
-
80
157
  const unique = [...new Set(selected)];
81
158
  return {
82
159
  fields: unique,
@@ -89,7 +166,6 @@ export function compileSelectFields(fields, schema) {
89
166
 
90
167
  export function compileSort(sort, schema) {
91
168
  if (!sort) return '';
92
-
93
169
  const parts = sort.map((entry, index) => {
94
170
  const descending = entry.startsWith('-');
95
171
  const field = descending ? entry.slice(1) : entry;
@@ -97,7 +173,6 @@ export function compileSort(sort, schema) {
97
173
  resolveField(schema, field, `sort.${index}`);
98
174
  return `${quoteIdentifier(field, 'field name')} ${descending ? 'DESC' : 'ASC'}`;
99
175
  });
100
-
101
176
  return parts.length ? ` ORDER BY ${parts.join(', ')}` : '';
102
177
  }
103
178
 
@@ -105,9 +180,49 @@ function escapeLike(value) {
105
180
  return String(value).replace(/[\\%_]/g, '\\$&');
106
181
  }
107
182
 
108
- function compileOperator(fieldSql, operator, value, path) {
109
- if (!FILTER_OPERATORS.has(operator)) throw queryError(`Unknown filter operator: ${operator}`, path);
183
+ export function compileSearch(search, schema) {
184
+ if (!search) return { sql: '', params: [] };
185
+ const fields = Object.entries(schema?.fields ?? {})
186
+ .filter(([, field]) => SEARCHABLE_TYPES.has(field.type))
187
+ .map(([name]) => name);
188
+ if (fields.length === 0) return { sql: ' WHERE 0 = 1', params: [] };
189
+ const needle = `%${escapeLike(search)}%`;
190
+ return {
191
+ sql: ` WHERE (${fields.map((field) => `${quoteIdentifier(field, 'field name')} LIKE ? ESCAPE '\\\\'`).join(' OR ')})`,
192
+ params: fields.map(() => needle),
193
+ };
194
+ }
195
+
196
+ export function compileAggregate(aggregate, groupBy, schema) {
197
+ if (!aggregate) return null;
198
+ const groups = groupBy ?? [];
199
+ for (const field of groups) resolveField(schema, field, `groupBy.${field}`);
200
+ const selections = groups.map((field) => quoteIdentifier(field, 'field name'));
201
+ const aliases = new Set(groups);
110
202
 
203
+ for (const [fn, fields] of Object.entries(aggregate)) {
204
+ for (const field of fields) {
205
+ if (field === '*' && fn !== 'count') throw queryError(`${fn} does not support *`, `aggregate.${fn}`);
206
+ if (field !== '*') resolveField(schema, field, `aggregate.${fn}.${field}`);
207
+ const column = field === '*' ? '*' : quoteIdentifier(field, 'field name');
208
+ const sqlFn = fn === 'countDistinct' ? 'COUNT' : fn.toUpperCase();
209
+ const expression = fn === 'countDistinct' ? `${sqlFn}(DISTINCT ${column})` : `${sqlFn}(${column})`;
210
+ const rawAlias = field === '*' ? fn : `${fn}_${field}`;
211
+ let alias = rawAlias;
212
+ let suffix = 2;
213
+ while (aliases.has(alias)) alias = `${rawAlias}_${suffix++}`;
214
+ aliases.add(alias);
215
+ selections.push(`${expression} AS ${quoteIdentifier(alias, 'aggregate alias')}`);
216
+ }
217
+ }
218
+ return {
219
+ sql: selections.join(', '),
220
+ groupSql: groups.length ? ` GROUP BY ${groups.map((field) => quoteIdentifier(field, 'field name')).join(', ')}` : '',
221
+ };
222
+ }
223
+
224
+ function compileOperator(fieldSql, operator, value, path, limits) {
225
+ if (!FILTER_OPERATORS.has(operator)) throw queryError(`Unknown filter operator: ${operator}`, path);
111
226
  switch (operator) {
112
227
  case '_eq':
113
228
  if (value === null) throw queryError('Use _null for NULL comparisons', path);
@@ -122,12 +237,10 @@ function compileOperator(fieldSql, operator, value, path) {
122
237
  case '_in':
123
238
  case '_nin': {
124
239
  if (!Array.isArray(value)) throw queryError(`${operator} requires an array`, path);
240
+ if (value.length > limits.maxInValues) throw queryError(`${operator} accepts at most ${limits.maxInValues} values`, path);
125
241
  if (value.length === 0) return { sql: operator === '_in' ? '0 = 1' : '1 = 1', params: [] };
126
242
  const placeholders = value.map(() => '?').join(', ');
127
- return {
128
- sql: `${fieldSql} ${operator === '_in' ? 'IN' : 'NOT IN'} (${placeholders})`,
129
- params: value,
130
- };
243
+ return { sql: `${fieldSql} ${operator === '_in' ? 'IN' : 'NOT IN'} (${placeholders})`, params: value };
131
244
  }
132
245
  case '_null':
133
246
  case '_nnull': {
@@ -135,59 +248,48 @@ function compileOperator(fieldSql, operator, value, path) {
135
248
  const wantsNull = operator === '_null' ? value : !value;
136
249
  return { sql: `${fieldSql} IS ${wantsNull ? '' : 'NOT '}NULL`, params: [] };
137
250
  }
138
- case '_contains':
139
- return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`%${escapeLike(value)}%`] };
140
- case '_starts_with':
141
- return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`${escapeLike(value)}%`] };
142
- case '_ends_with':
143
- return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`%${escapeLike(value)}`] };
144
- default:
145
- throw queryError(`Unknown filter operator: ${operator}`, path);
251
+ case '_contains': return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`%${escapeLike(value)}%`] };
252
+ case '_starts_with': return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`${escapeLike(value)}%`] };
253
+ case '_ends_with': return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`%${escapeLike(value)}`] };
254
+ default: throw queryError(`Unknown filter operator: ${operator}`, path);
146
255
  }
147
256
  }
148
257
 
149
- function compileFilterObject(filter, schema, path = 'filter') {
150
- if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
151
- throw queryError('Filter node must be an object', path);
152
- }
258
+ function compileFilterObject(filter, schema, path, limits, state, depth) {
259
+ if (!filter || typeof filter !== 'object' || Array.isArray(filter)) throw queryError('Filter node must be an object', path);
260
+ if (depth > limits.maxFilterDepth) throw queryError(`Filter depth cannot exceed ${limits.maxFilterDepth}`, path);
261
+ state.nodes += 1;
262
+ if (state.nodes > limits.maxFilterNodes) throw queryError(`Filter cannot contain more than ${limits.maxFilterNodes} nodes`, path);
153
263
 
154
264
  const fragments = [];
155
265
  const params = [];
156
-
157
266
  for (const [key, value] of Object.entries(filter)) {
158
267
  if (key === '_and' || key === '_or') {
159
- if (!Array.isArray(value) || value.length === 0) {
160
- throw queryError(`${key} requires a non-empty array`, `${path}.${key}`);
161
- }
162
- const children = value.map((child, index) =>
163
- compileFilterObject(child, schema, `${path}.${key}.${index}`));
268
+ if (!Array.isArray(value) || value.length === 0) throw queryError(`${key} requires a non-empty array`, `${path}.${key}`);
269
+ const children = value.map((child, index) => compileFilterObject(child, schema, `${path}.${key}.${index}`, limits, state, depth + 1));
164
270
  fragments.push(`(${children.map((child) => child.sql).join(key === '_and' ? ' AND ' : ' OR ')})`);
165
271
  for (const child of children) params.push(...child.params);
166
272
  continue;
167
273
  }
168
-
169
274
  resolveField(schema, key, `${path}.${key}`);
170
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
171
- throw queryError('Field filters must be operator objects', `${path}.${key}`);
172
- }
173
-
275
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw queryError('Field filters must be operator objects', `${path}.${key}`);
174
276
  const fieldSql = quoteIdentifier(key, 'field name');
175
277
  const fieldFragments = [];
176
278
  for (const [operator, operatorValue] of Object.entries(value)) {
177
- const compiled = compileOperator(fieldSql, operator, operatorValue, `${path}.${key}.${operator}`);
279
+ const compiled = compileOperator(fieldSql, operator, operatorValue, `${path}.${key}.${operator}`, limits);
178
280
  fieldFragments.push(compiled.sql);
179
281
  params.push(...compiled.params);
180
282
  }
181
283
  if (fieldFragments.length === 0) throw queryError('Field filter cannot be empty', `${path}.${key}`);
182
284
  fragments.push(`(${fieldFragments.join(' AND ')})`);
183
285
  }
184
-
185
286
  if (fragments.length === 0) throw queryError('Filter cannot be empty', path);
186
287
  return { sql: fragments.join(' AND '), params };
187
288
  }
188
289
 
189
- export function compileFilter(filter, schema) {
290
+ export function compileFilter(filter, schema, options = {}) {
190
291
  if (!filter) return { sql: '', params: [] };
191
- const compiled = compileFilterObject(filter, schema);
292
+ const limits = { ...QUERY_LIMITS, ...options };
293
+ const compiled = compileFilterObject(filter, schema, 'filter', limits, { nodes: 0 }, 1);
192
294
  return { sql: ` WHERE ${compiled.sql}`, params: compiled.params };
193
295
  }
package/src/redis.js ADDED
@@ -0,0 +1,300 @@
1
+ import { createHash } from 'node:crypto';
2
+ import net from 'node:net';
3
+ import tls from 'node:tls';
4
+
5
+ function redisError(code, message, cause = null) {
6
+ const error = new Error(message, cause ? { cause } : undefined);
7
+ error.code = code;
8
+ return error;
9
+ }
10
+
11
+ export function redactRedisUrl(value) {
12
+ if (!value) return null;
13
+ try {
14
+ const url = new URL(value);
15
+ if (url.username) url.username = '***';
16
+ if (url.password) url.password = '***';
17
+ return url.toString();
18
+ } catch {
19
+ return '<invalid-redis-url>';
20
+ }
21
+ }
22
+
23
+ export function parseRedisUrl(value) {
24
+ if (!value) throw redisError('INVALID_REDIS_CONFIG', 'REDIS_URL is required for Redis shared state');
25
+ let url;
26
+ try { url = new URL(value); } catch { throw redisError('INVALID_REDIS_CONFIG', 'REDIS_URL must be a valid URL'); }
27
+ if (!['redis:', 'rediss:'].includes(url.protocol)) {
28
+ throw redisError('INVALID_REDIS_CONFIG', 'REDIS_URL must use redis:// or rediss://');
29
+ }
30
+ const database = url.pathname && url.pathname !== '/' ? Number(url.pathname.slice(1)) : 0;
31
+ if (!Number.isInteger(database) || database < 0 || database > 15) {
32
+ throw redisError('INVALID_REDIS_CONFIG', 'REDIS_URL database must be an integer between 0 and 15');
33
+ }
34
+ return Object.freeze({
35
+ tls: url.protocol === 'rediss:',
36
+ host: url.hostname,
37
+ port: Number(url.port || 6379),
38
+ username: decodeURIComponent(url.username || ''),
39
+ password: decodeURIComponent(url.password || ''),
40
+ database,
41
+ });
42
+ }
43
+
44
+ function encodeCommand(args) {
45
+ const parts = [`*${args.length}\r\n`];
46
+ for (const arg of args) {
47
+ const value = Buffer.from(String(arg));
48
+ parts.push(`$${value.length}\r\n`, value, '\r\n');
49
+ }
50
+ return Buffer.concat(parts.map((part) => Buffer.isBuffer(part) ? part : Buffer.from(part)));
51
+ }
52
+
53
+ function readLine(buffer, offset) {
54
+ const end = buffer.indexOf('\r\n', offset);
55
+ if (end === -1) return null;
56
+ return { value: buffer.toString('utf8', offset, end), next: end + 2 };
57
+ }
58
+
59
+ function parseReply(buffer, offset = 0) {
60
+ if (offset >= buffer.length) return null;
61
+ const type = String.fromCharCode(buffer[offset]);
62
+ const line = readLine(buffer, offset + 1);
63
+ if (!line) return null;
64
+
65
+ if (type === '+' || type === '-' || type === ':') {
66
+ const value = type === ':' ? Number(line.value) : line.value;
67
+ return { value, error: type === '-', next: line.next };
68
+ }
69
+ if (type === '$') {
70
+ const length = Number(line.value);
71
+ if (length === -1) return { value: null, next: line.next };
72
+ const end = line.next + length;
73
+ if (buffer.length < end + 2) return null;
74
+ return { value: buffer.toString('utf8', line.next, end), next: end + 2 };
75
+ }
76
+ if (type === '*') {
77
+ const count = Number(line.value);
78
+ if (count === -1) return { value: null, next: line.next };
79
+ let next = line.next;
80
+ const value = [];
81
+ for (let index = 0; index < count; index += 1) {
82
+ const child = parseReply(buffer, next);
83
+ if (!child) return null;
84
+ if (child.error) return child;
85
+ value.push(child.value);
86
+ next = child.next;
87
+ }
88
+ return { value, next };
89
+ }
90
+ throw redisError('REDIS_PROTOCOL_ERROR', `Unsupported Redis RESP type: ${type}`);
91
+ }
92
+
93
+ export class RedisClient {
94
+ constructor({ url, connectTimeoutMs = 5_000, commandTimeoutMs = 3_000, logger = console } = {}) {
95
+ this.config = parseRedisUrl(url);
96
+ this.connectTimeoutMs = connectTimeoutMs;
97
+ this.commandTimeoutMs = commandTimeoutMs;
98
+ this.logger = logger;
99
+ this.socket = null;
100
+ this.buffer = Buffer.alloc(0);
101
+ this.pending = [];
102
+ this.connecting = null;
103
+ this.closed = false;
104
+ }
105
+
106
+ async connect() {
107
+ if (this.socket && !this.socket.destroyed) return this;
108
+ if (this.connecting) return this.connecting;
109
+ if (this.closed) throw redisError('REDIS_CLOSED', 'Redis client is closed');
110
+
111
+ this.connecting = new Promise((resolve, reject) => {
112
+ const options = { host: this.config.host, port: this.config.port };
113
+ const socket = this.config.tls ? tls.connect(options) : net.createConnection(options);
114
+ const timer = setTimeout(() => {
115
+ socket.destroy(redisError('REDIS_CONNECT_TIMEOUT', 'Redis connection timed out'));
116
+ }, this.connectTimeoutMs);
117
+
118
+ const fail = (error) => {
119
+ clearTimeout(timer);
120
+ this.connecting = null;
121
+ reject(redisError('REDIS_CONNECT_FAILED', 'Redis connection failed', error));
122
+ };
123
+ socket.once('error', fail);
124
+ socket.once('connect', async () => {
125
+ clearTimeout(timer);
126
+ socket.off('error', fail);
127
+ this.socket = socket;
128
+ this.buffer = Buffer.alloc(0);
129
+ socket.on('data', (chunk) => this.#onData(chunk));
130
+ socket.on('error', (error) => this.#onSocketFailure(error));
131
+ socket.on('close', () => this.#onSocketFailure(redisError('REDIS_DISCONNECTED', 'Redis disconnected')));
132
+ try {
133
+ if (this.config.password) {
134
+ if (this.config.username) await this.command('AUTH', this.config.username, this.config.password);
135
+ else await this.command('AUTH', this.config.password);
136
+ }
137
+ if (this.config.database) await this.command('SELECT', this.config.database);
138
+ this.connecting = null;
139
+ resolve(this);
140
+ } catch (error) {
141
+ socket.destroy();
142
+ this.connecting = null;
143
+ reject(error);
144
+ }
145
+ });
146
+ });
147
+ return this.connecting;
148
+ }
149
+
150
+ #onData(chunk) {
151
+ this.buffer = Buffer.concat([this.buffer, chunk]);
152
+ while (this.pending.length) {
153
+ let parsed;
154
+ try { parsed = parseReply(this.buffer); } catch (error) {
155
+ this.#onSocketFailure(error);
156
+ return;
157
+ }
158
+ if (!parsed) return;
159
+ this.buffer = this.buffer.subarray(parsed.next);
160
+ const pending = this.pending.shift();
161
+ clearTimeout(pending.timer);
162
+ if (parsed.error) pending.reject(redisError('REDIS_COMMAND_FAILED', `Redis command failed: ${parsed.value}`));
163
+ else pending.resolve(parsed.value);
164
+ }
165
+ }
166
+
167
+ #onSocketFailure(error) {
168
+ const socket = this.socket;
169
+ this.socket = null;
170
+ if (socket && !socket.destroyed) socket.destroy();
171
+ const pending = this.pending.splice(0);
172
+ for (const request of pending) {
173
+ clearTimeout(request.timer);
174
+ request.reject(redisError('REDIS_UNAVAILABLE', 'Redis command interrupted', error));
175
+ }
176
+ }
177
+
178
+ async command(...args) {
179
+ if (!this.socket || this.socket.destroyed) await this.connect();
180
+ return new Promise((resolve, reject) => {
181
+ const timer = setTimeout(() => {
182
+ const index = this.pending.findIndex((entry) => entry.resolve === resolve);
183
+ if (index >= 0) this.pending.splice(index, 1);
184
+ reject(redisError('REDIS_COMMAND_TIMEOUT', 'Redis command timed out'));
185
+ }, this.commandTimeoutMs);
186
+ this.pending.push({ resolve, reject, timer });
187
+ this.socket.write(encodeCommand(args));
188
+ });
189
+ }
190
+
191
+ async ping() {
192
+ return (await this.command('PING')) === 'PONG';
193
+ }
194
+
195
+ async close() {
196
+ this.closed = true;
197
+ const socket = this.socket;
198
+ this.socket = null;
199
+ if (!socket || socket.destroyed) return;
200
+ try { socket.end(encodeCommand(['QUIT'])); } catch {}
201
+ socket.destroy();
202
+ }
203
+ }
204
+
205
+ function assertPrefix(prefix) {
206
+ const value = String(prefix || 'yuncms:default:');
207
+ if (value.length < 3 || value.length > 128 || /[\r\n\0]/.test(value)) {
208
+ throw redisError('INVALID_REDIS_CONFIG', 'Redis prefix must be between 3 and 128 safe characters');
209
+ }
210
+ return value.endsWith(':') ? value : `${value}:`;
211
+ }
212
+
213
+ export class RedisCacheStore {
214
+ constructor({ client, prefix = 'yuncms:default:', namespace = 'permission', ttlMs = 30_000, logger = console } = {}) {
215
+ if (!client?.command) throw redisError('INVALID_REDIS_CONFIG', 'RedisCacheStore requires a Redis command client');
216
+ this.client = client;
217
+ this.prefix = assertPrefix(prefix);
218
+ this.namespace = String(namespace);
219
+ this.ttlMs = ttlMs;
220
+ this.logger = logger;
221
+ }
222
+
223
+ generationKey() { return `${this.prefix}${this.namespace}:generation`; }
224
+
225
+ async #generation() {
226
+ const value = await this.client.command('GET', this.generationKey());
227
+ return value == null ? '0' : String(value);
228
+ }
229
+
230
+ async #key(key) {
231
+ return `${this.prefix}${this.namespace}:${await this.#generation()}:${String(key)}`;
232
+ }
233
+
234
+ async get(key) {
235
+ try {
236
+ const raw = await this.client.command('GET', await this.#key(key));
237
+ if (raw == null) return undefined;
238
+ const parsed = JSON.parse(raw);
239
+ if (!parsed || parsed.v !== 1 || !Object.hasOwn(parsed, 'value')) return undefined;
240
+ return parsed.value;
241
+ } catch (error) {
242
+ this.logger?.warn?.('Redis cache read failed; falling back to source of truth', { code: error?.code });
243
+ return undefined;
244
+ }
245
+ }
246
+
247
+ async set(key, value, { ttlMs = this.ttlMs } = {}) {
248
+ try {
249
+ const payload = JSON.stringify({ v: 1, value });
250
+ await this.client.command('SET', await this.#key(key), payload, 'PX', ttlMs);
251
+ } catch (error) {
252
+ this.logger?.warn?.('Redis cache write failed; continuing without cache', { code: error?.code });
253
+ }
254
+ return value;
255
+ }
256
+
257
+ async delete(key) {
258
+ try { return Number(await this.client.command('DEL', await this.#key(key))) > 0; } catch { return false; }
259
+ }
260
+
261
+ async clear() {
262
+ try {
263
+ await this.client.command('INCR', this.generationKey());
264
+ return true;
265
+ } catch (error) {
266
+ this.logger?.warn?.('Redis cache generation invalidation failed', { code: error?.code });
267
+ return false;
268
+ }
269
+ }
270
+ }
271
+
272
+ const RATE_LIMIT_SCRIPT = `
273
+ local count = redis.call('INCR', KEYS[1])
274
+ if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
275
+ local ttl = redis.call('PTTL', KEYS[1])
276
+ return {count, ttl}
277
+ `.trim();
278
+
279
+ export class RedisFixedWindowStore {
280
+ constructor({ client, prefix = 'yuncms:default:', logger = console } = {}) {
281
+ if (!client?.command) throw redisError('INVALID_REDIS_CONFIG', 'RedisFixedWindowStore requires a Redis command client');
282
+ this.client = client;
283
+ this.prefix = assertPrefix(prefix);
284
+ this.logger = logger;
285
+ }
286
+
287
+ async consume(identity, { windowMs, max, scope = 'api' } = {}) {
288
+ const digest = createHash('sha256').update(String(identity)).digest('hex');
289
+ const key = `${this.prefix}rate:${scope}:${digest}`;
290
+ const result = await this.client.command('EVAL', RATE_LIMIT_SCRIPT, 1, key, windowMs);
291
+ const count = Number(result?.[0] ?? 0);
292
+ const ttlMs = Math.max(1, Number(result?.[1] ?? windowMs));
293
+ return {
294
+ count,
295
+ remaining: Math.max(0, max - count),
296
+ retryAfterMs: ttlMs,
297
+ resetAt: Date.now() + ttlMs,
298
+ };
299
+ }
300
+ }