@zerodep-dev/easy-query-ts-cache 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transport.js","sources":["../src/transport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAkBH,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;AAElC,kCAAkC;AAClC,MAAM,UAAU,cAAc,CAC5B,MAAc,EACd,IAAuB,EACvB,QAAQ,GAAG,QAAQ;IAEnB,MAAM,MAAM,GAAG,CAAC,CAAW,EAAE,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAiB,CAAC,CAAC;IAChF,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC;IAChC,IAAI,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,IAAI,QAAQ;QAAE,OAAO,CAAC,KAAK,CAAC,CAAC;IAC7D,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,IAAI,IAAI,GAAa,EAAE,CAAC;IACxB,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACvB,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,QAAQ,EAAE,CAAC;YAChF,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;YACzB,IAAI,GAAG,EAAE,CAAC;QACZ,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACjB,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;IACzB,OAAO,KAAK,CAAC;AACf,CAAC;AAED,+CAA+C;AAC/C,MAAM,UAAU,cAAc,CAC5B,IAAY;IAEZ,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAS,CAAC;QACtC,OAAO,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;IAC9E,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe;IAC7B,MAAM,SAAS,GAAG,IAAI,GAAG,EAA2D,CAAC;IACrF,OAAO;QACL,IAAI,CAAC,IAAI;YACP,cAAc,CAAC,GAAG,EAAE;gBAClB,KAAK,MAAM,QAAQ,IAAI,SAAS;oBAAE,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;YAC1D,CAAC,CAAC,CAAC;YACH,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;QAC3B,CAAC;QACD,MAAM,CAAC,MAAM,EAAE,OAAO;YACpB,MAAM,QAAQ,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;YACrC,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YACxB,OAAO,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE;gBAC1B,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;gBAC3B,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;YAC3B,CAAC,CAAC,CAAC;QACL,CAAC;QACD,qBAAqB;QACrB,KAAK;YACH,KAAK,MAAM,QAAQ,IAAI,SAAS;gBAAE,QAAQ,CAAC,OAAO,EAAE,CAAC;QACvD,CAAC;KACF,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@zerodep-dev/easy-query-ts-cache",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Second-level cache (kv / all modes, evicted after commit) for @zerodep-dev/easy-query-ts",
5
+ "license": "Apache-2.0",
6
+ "author": "lionheart (https://github.com/kenconnet666)",
7
+ "homepage": "https://github.com/kenconnet666/easy-query-ts#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/kenconnet666/easy-query-ts/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/kenconnet666/easy-query-ts.git",
14
+ "directory": "packages/cache"
15
+ },
16
+ "keywords": [
17
+ "cache",
18
+ "redis",
19
+ "orm",
20
+ "easy-query"
21
+ ],
22
+ "exports": {
23
+ ".": {
24
+ "@easy-query-ts/source": "./src/index.ts",
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "src"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "peerDependencies": {
37
+ "@zerodep-dev/easy-query-ts": "^0.2.0"
38
+ },
39
+ "devDependencies": {
40
+ "@zerodep-dev/easy-query-ts": "0.2.0"
41
+ },
42
+ "type": "module",
43
+ "sideEffects": false,
44
+ "scripts": {
45
+ "build": "tsc -b tsconfig.build.json"
46
+ }
6
47
  }
package/src/index.ts ADDED
@@ -0,0 +1,457 @@
1
+ /**
2
+ * 二级缓存(对应 Java 版的 easy-cache):kv 模式按主键缓存单行,all 模式缓存整张表(适合配置、省市这类小表)。
3
+ *
4
+ * ```ts
5
+ * const cache = createCache({ entities: [User, Province] })
6
+ * const db = createDb({ schema, adapter, interceptors: [cache.interceptor] }) // 写入这些实体时失效
7
+ * const users = cache.of(db)
8
+ * await users.kv(User).find(1) // 先查缓存,没有再查库并写回
9
+ * await users.kv(User).where((u) => u.enabled.eq(true)).find(1) // 条件是缓存字段的一部分
10
+ * await users.all(Province).where((p) => p.name.startsWith('浙')).toPage(1, 20)
11
+ * ```
12
+ *
13
+ * 三种部署(见 stores.ts):单实例用默认的本机缓存;多实例只用外部 Redis;多实例本机 + 外部 Redis,再加失效通知。
14
+ * 本库不带 Redis 与 MQ 的驱动,后两种由使用方实现 CacheStore 与 CacheTransport。
15
+ *
16
+ * 一致性(对应官方的"延迟删除 + 逻辑删除"方案):
17
+ * - 失效走拦截器:insert 按插入的主键,update / delete 按改之前的行(拦截器对缓存的实体声明 before,核心先按同样条件读出受影响的行),
18
+ * 所以表达式更新也知道该删哪些键——官方要自己实现 CacheKeysProvider 按更新时间回查
19
+ * - 写入时删一次,事务提交后再删一次(事务里别的请求可能把旧值读回缓存);回滚了第二次也不删,第一次删掉的只是少命中一次
20
+ * - 绕过本库的写入(直接改库、别的服务)不会失效,靠过期时间兜底,或接 CDC 调用 evict
21
+ * - 两级存储时:提交后删本机与共享层、发通知,别的实例收到后只删本机层;通知断过时清空本机层。读库前记下代数,
22
+ * 期间这个键被失效过(本机的或收到的)就不写回;lateEvictMs 在提交后隔一会儿再删一次、再发一次
23
+ * (挡住从库延迟与慢请求把旧值写回)。通知丢了靠本机层的过期时间兜底
24
+ * 读写拦截器(租户等)照常生效,它们加的条件是缓存字段的一部分:不同租户各缓存各的。
25
+ */
26
+ import {
27
+ flattenRow,
28
+ nestRow,
29
+ type AnySchema,
30
+ type Cond,
31
+ type Db,
32
+ type Entity,
33
+ type EntityKey,
34
+ type Interceptor,
35
+ type KeyOf,
36
+ type Page,
37
+ type Ref,
38
+ type Row,
39
+ } from '@zerodep-dev/easy-query-ts';
40
+
41
+ declare module '@zerodep-dev/easy-query-ts' {
42
+ interface InterceptorNames {
43
+ /** 二级缓存的失效(easy-query-ts-cache) */
44
+ cache: true;
45
+ }
46
+ }
47
+
48
+ import { memoryStore, type CacheStore } from './stores.ts';
49
+ import { decodeEviction, encodeEviction, type CacheTransport } from './transport.ts';
50
+
51
+ export {
52
+ memoryStore,
53
+ tieredStore,
54
+ type CacheStore,
55
+ type MemoryStoreOptions,
56
+ type TieredStoreOptions,
57
+ } from './stores.ts';
58
+ export { memoryTransport, type CacheTransport } from './transport.ts';
59
+
60
+ export interface CacheOptions {
61
+ /** 要缓存的实体:写入它们时失效(只有列在这里的实体能用 kv / all) */
62
+ readonly entities: readonly Entity[];
63
+ /** 默认本机的 memoryStore();多实例用使用方实现的 Redis 存储,或 tieredStore 叠本机与 Redis */
64
+ readonly store?: CacheStore;
65
+ /** 缓存的过期时间,默认 5 分钟 */
66
+ readonly ttlMs?: number;
67
+ /** 查不到的行也缓存(防穿透),过期时间默认 10 秒 */
68
+ readonly nullTtlMs?: number;
69
+ /** 键的前缀,默认 'CACHE':键是 CACHE:表名:主键、CACHE:表名:all */
70
+ readonly keyPrefix?: string;
71
+ /** 失效通知的传输:多实例且有本机层时,让别的实例的本机层跟着失效 */
72
+ readonly transport?: CacheTransport;
73
+ /** 提交后隔多久再删一次、再发一次通知(毫秒):挡住从库延迟与慢请求把旧值写回;默认不做 */
74
+ readonly lateEvictMs?: number;
75
+ }
76
+
77
+ /** 键的类型:单列主键的值 */
78
+ type KeyValue = string | number | bigint;
79
+
80
+ /** 缓存里表示"库里没有"的值 */
81
+ const MISSING = 'null';
82
+
83
+ export class EasyCache {
84
+ /** 放进 createDb 的 interceptors:写入缓存的实体时失效 */
85
+ readonly interceptor: Interceptor;
86
+ private readonly store: CacheStore;
87
+ private readonly tables: ReadonlySet<string>;
88
+ private readonly ttlMs: number;
89
+ private readonly nullTtlMs: number;
90
+ private readonly prefix: string;
91
+ private readonly transport: CacheTransport | undefined;
92
+ private readonly lateEvictMs: number;
93
+ /** 通知的来源标记:传输把消息也发给自己,收到时跳过自己发的 */
94
+ private readonly origin = Math.random().toString(36).slice(2) + Date.now().toString(36);
95
+ /** 失效通知的订阅:ready() 等它,close() 退订 */
96
+ private readonly listening: Promise<(() => Promise<void>) | undefined>;
97
+ /** 写回守卫:每次失效加一;evicted 记下每个键最后一次失效时的代数 */
98
+ private generation = 0;
99
+ private readonly evicted = new Map<string, number>();
100
+ /** evicted 太大时清空,这之前开始的读一律不写回 */
101
+ private floor = 0;
102
+
103
+ constructor(options: CacheOptions) {
104
+ this.store = options.store ?? memoryStore();
105
+ this.tables = new Set(options.entities.map((e) => e.table));
106
+ this.ttlMs = options.ttlMs ?? 300_000;
107
+ this.nullTtlMs = options.nullTtlMs ?? 10_000;
108
+ this.prefix = options.keyPrefix ?? 'CACHE';
109
+ this.transport = options.transport;
110
+ this.lateEvictMs = options.lateEvictMs ?? 0;
111
+ this.listening = this.transport
112
+ ? this.transport.listen(
113
+ (text) => {
114
+ const message = decodeEviction(text);
115
+ if (message && message.origin !== this.origin) void this.applyRemote(message.keys);
116
+ },
117
+ () => void this.resetLocal(),
118
+ )
119
+ : Promise.resolve(undefined);
120
+ // 订阅失败由 ready() 报出;没人等时不当作未处理的拒绝
121
+ this.listening.catch(() => undefined);
122
+ this.interceptor = {
123
+ name: 'cache',
124
+ before: (entity) => this.tables.has(entity.table),
125
+ afterWrite: async (event) => {
126
+ if (!this.tables.has(event.entity.table)) return;
127
+ const rows = event.kind === 'insert' ? event.rows : (event.before ?? []);
128
+ const keys = [this.allKey(event.entity), ...this.keysOf(event.entity, rows)];
129
+ // 延迟双删:现在删一次,提交后再删一次
130
+ this.markEvicted(keys);
131
+ await this.store.delete(keys);
132
+ event.afterCommit(() => this.remove(keys));
133
+ },
134
+ };
135
+ }
136
+
137
+ /** 绑定一个 Db(事务里的 tx 也行):缓存没命中时用它查库 */
138
+ of<S extends AnySchema>(db: Db<S>): CacheClient<S> {
139
+ return new CacheClient(db, this);
140
+ }
141
+
142
+ /** 手动失效(CDC、别的服务改了库):给主键删这几行,不给删这个实体的全部 kv 之外的 all 缓存 */
143
+ async evict(entity: Entity, keys: readonly KeyValue[] = []): Promise<void> {
144
+ await this.remove([this.allKey(entity), ...keys.map((k) => this.kvKey(entity, k))]);
145
+ }
146
+
147
+ /** 等失效通知订阅好(部署时启动阶段调用;没有 transport 时立即完成) */
148
+ async ready(): Promise<void> {
149
+ await this.listening;
150
+ }
151
+
152
+ /** 退订失效通知(停机、测试结束) */
153
+ async close(): Promise<void> {
154
+ const stop = await this.listening.catch(() => undefined);
155
+ await stop?.();
156
+ }
157
+
158
+ /** 本实例的失效:删本机与共享层、发通知;配了 lateEvictMs 时过一会儿再来一次 */
159
+ private async remove(keys: readonly string[]): Promise<void> {
160
+ this.markEvicted(keys);
161
+ await this.store.delete(keys);
162
+ await this.notify(keys);
163
+ if (this.lateEvictMs > 0) {
164
+ const timer = setTimeout(() => {
165
+ this.markEvicted(keys);
166
+ void this.store
167
+ .delete(keys)
168
+ .then(() => this.notify(keys))
169
+ .catch(() => undefined);
170
+ }, this.lateEvictMs);
171
+ // 不让这个定时器拖住进程退出
172
+ (timer as { unref?: () => void }).unref?.();
173
+ }
174
+ }
175
+
176
+ private async notify(keys: readonly string[]): Promise<void> {
177
+ if (!this.transport) return;
178
+ for (const text of encodeEviction(this.origin, keys, this.transport.maxBytes)) {
179
+ await this.transport.send(text);
180
+ }
181
+ }
182
+
183
+ /** 别的实例的失效:只删本机层 */
184
+ private async applyRemote(keys: readonly string[]): Promise<void> {
185
+ this.markEvicted(keys);
186
+ if (this.store.evictLocal) await this.store.evictLocal(keys);
187
+ else await this.store.delete(keys);
188
+ }
189
+
190
+ /** 通知断过:中间的可能丢了,清空本机层,这之前开始的读不写回 */
191
+ private async resetLocal(): Promise<void> {
192
+ this.floor = ++this.generation;
193
+ this.evicted.clear();
194
+ await this.store.evictLocal?.(undefined);
195
+ }
196
+
197
+ private markEvicted(keys: readonly string[]): void {
198
+ this.generation++;
199
+ if (this.evicted.size > 10_000) {
200
+ this.evicted.clear();
201
+ this.floor = this.generation;
202
+ }
203
+ for (const key of keys) this.evicted.set(key, this.generation);
204
+ }
205
+
206
+ /** @internal 读库前调用:记下现在的代数,write 时带回来 */
207
+ since(): number {
208
+ return this.generation;
209
+ }
210
+
211
+ /** @internal 缓存里的值:undefined 是没缓存,null 是缓存了"库里没有" */
212
+ async read(key: string, field: string): Promise<string | null | undefined> {
213
+ const cached = await this.store.get(key, field);
214
+ return cached === MISSING ? null : cached;
215
+ }
216
+
217
+ /**
218
+ * @internal 写回缓存;value 为 undefined 表示库里没有(较短的过期时间)。
219
+ * since 是读库前的代数:期间这个键被失效过(读到的可能是旧值)就不写
220
+ */
221
+ async write(key: string, field: string, value: string | undefined, since: number): Promise<void> {
222
+ if (since < this.floor || (this.evicted.get(key) ?? 0) > since) return;
223
+ await this.store.set(
224
+ key,
225
+ field,
226
+ value ?? MISSING,
227
+ value === undefined ? this.nullTtlMs : this.ttlMs,
228
+ );
229
+ }
230
+
231
+ /** @internal */
232
+ check(entity: Entity, mode: string): void {
233
+ if (!this.tables.has(entity.table))
234
+ throw new Error(
235
+ `cache.${mode}(${entity.name}): list ${entity.name} in createCache({ entities }) so that writes evict it`,
236
+ );
237
+ }
238
+
239
+ /** @internal */
240
+ kvKey(entity: Entity, key: KeyValue): string {
241
+ return `${this.prefix}:${entity.table}:${String(key)}`;
242
+ }
243
+
244
+ /** @internal */
245
+ allKey(entity: Entity): string {
246
+ return `${this.prefix}:${entity.table}:all`;
247
+ }
248
+
249
+ private keysOf(entity: Entity, rows: readonly Readonly<Record<string, unknown>>[]): string[] {
250
+ const [pk] = entity.primaryKey;
251
+ if (pk === undefined || entity.primaryKey.length > 1) return [];
252
+ return rows
253
+ .map((row) => row[pk])
254
+ .filter((v): v is KeyValue => v !== undefined && v !== null)
255
+ .map((v) => this.kvKey(entity, v));
256
+ }
257
+ }
258
+
259
+ /** 建一个缓存;它的 interceptor 要放进 createDb */
260
+ export function createCache(options: CacheOptions): EasyCache {
261
+ return new EasyCache(options);
262
+ }
263
+
264
+ /** 绑定了 Db 的缓存入口 */
265
+ export class CacheClient<S extends AnySchema> {
266
+ private readonly db: Db<S>;
267
+ private readonly cache: EasyCache;
268
+
269
+ constructor(db: Db<S>, cache: EasyCache) {
270
+ this.db = db;
271
+ this.cache = cache;
272
+ }
273
+
274
+ /** kv 模式(对应官方 kvStorage):按单列主键缓存单行 */
275
+ kv<E extends S['entities'][EntityKey<S>]>(entity: E): KvCache<S, KeyOf<S, E>> {
276
+ this.cache.check(entity, 'kv');
277
+ if (entity.primaryKey.length !== 1)
278
+ throw new Error(`cache.kv(${entity.name}) needs a single-column primary key`);
279
+ return new KvCache(this.db, this.cache, entity, []);
280
+ }
281
+
282
+ /** all 模式(对应官方 allStorage):整张表按条件缓存成一个列表,toList / toPage 在内存里取 */
283
+ all<E extends S['entities'][EntityKey<S>]>(entity: E): AllCache<S, KeyOf<S, E>> {
284
+ this.cache.check(entity, 'all');
285
+ return new AllCache(this.db, this.cache, entity, []);
286
+ }
287
+ }
288
+
289
+ type Filter<S extends AnySchema, K extends EntityKey<S>> = (r: Ref<S, K>) => Cond;
290
+ type RowOf<S extends AnySchema, K extends EntityKey<S>> = Row<S['entities'][K]>;
291
+
292
+ export class KvCache<S extends AnySchema, K extends EntityKey<S>> {
293
+ private readonly db: Db<S>;
294
+ private readonly cache: EasyCache;
295
+ private readonly entity: Entity;
296
+ private readonly filters: readonly Filter<S, K>[];
297
+
298
+ constructor(db: Db<S>, cache: EasyCache, entity: Entity, filters: readonly Filter<S, K>[]) {
299
+ this.db = db;
300
+ this.cache = cache;
301
+ this.entity = entity;
302
+ this.filters = filters;
303
+ }
304
+
305
+ /** 加条件(对应官方 kvStorage 的 where):不满足条件的行当作没有;条件连同拦截器加的条件是缓存字段 */
306
+ where(filter: Filter<S, K>): KvCache<S, K> {
307
+ return new KvCache(this.db, this.cache, this.entity, [...this.filters, filter]);
308
+ }
309
+
310
+ async find(key: KeyValue): Promise<RowOf<S, K> | null> {
311
+ return (await this.findMany([key]))[0] ?? null;
312
+ }
313
+
314
+ /** 几个主键一起取:没命中的用一条 IN 查询补上;返回按给出的顺序,库里没有的跳过 */
315
+ async findMany(keys: readonly KeyValue[]): Promise<RowOf<S, K>[]> {
316
+ const field = this.field();
317
+ const found = new Map<string, RowOf<S, K>>();
318
+ const missing: KeyValue[] = [];
319
+ for (const key of keys) {
320
+ const cached = await this.cache.read(this.cache.kvKey(this.entity, key), field);
321
+ if (cached === undefined) missing.push(key);
322
+ else if (cached !== null)
323
+ found.set(String(key), deserialize(this.entity, cached) as RowOf<S, K>);
324
+ }
325
+ if (missing.length > 0) {
326
+ const since = this.cache.since();
327
+ const pk = this.entity.primaryKey[0]!;
328
+ const rows = (await this.query()
329
+ .where((r: any) => r[pk].in(missing))
330
+ .toList()) as Record<string, unknown>[];
331
+ const byKey = new Map(rows.map((row) => [String(row[pk]), row]));
332
+ for (const key of missing) {
333
+ const row = byKey.get(String(key));
334
+ await this.cache.write(
335
+ this.cache.kvKey(this.entity, key),
336
+ field,
337
+ row && serialize(this.entity, row),
338
+ since,
339
+ );
340
+ if (row) found.set(String(key), row as RowOf<S, K>);
341
+ }
342
+ }
343
+ return keys
344
+ .map((key) => found.get(String(key)))
345
+ .filter((row): row is RowOf<S, K> => row !== undefined);
346
+ }
347
+
348
+ private query() {
349
+ const q = this.db.queryable(this.entity as never) as any;
350
+ return this.filters.reduce((acc, f) => acc.where(f), q);
351
+ }
352
+
353
+ /** 缓存字段:条件(含拦截器加的)的 SQL 与参数 */
354
+ private field(): string {
355
+ const { sql, params } = this.query().toSQL();
356
+ return fingerprint(sql, params);
357
+ }
358
+ }
359
+
360
+ export class AllCache<S extends AnySchema, K extends EntityKey<S>> {
361
+ private readonly db: Db<S>;
362
+ private readonly cache: EasyCache;
363
+ private readonly entity: Entity;
364
+ private readonly filters: readonly Filter<S, K>[];
365
+
366
+ constructor(db: Db<S>, cache: EasyCache, entity: Entity, filters: readonly Filter<S, K>[]) {
367
+ this.db = db;
368
+ this.cache = cache;
369
+ this.entity = entity;
370
+ this.filters = filters;
371
+ }
372
+
373
+ where(filter: Filter<S, K>): AllCache<S, K> {
374
+ return new AllCache(this.db, this.cache, this.entity, [...this.filters, filter]);
375
+ }
376
+
377
+ async toList(): Promise<RowOf<S, K>[]> {
378
+ const query = this.filters.reduce(
379
+ (acc, f) => acc.where(f),
380
+ this.db.queryable(this.entity as never) as any,
381
+ );
382
+ const pk = this.entity.primaryKey;
383
+ const ordered = pk.length > 0 ? query.orderBy((r: any) => pk.map((k) => r[k])) : query;
384
+ const { sql, params } = ordered.toSQL();
385
+ const key = this.cache.allKey(this.entity);
386
+ const field = fingerprint(sql, params);
387
+ let text = await this.cache.read(key, field);
388
+ if (typeof text !== 'string') {
389
+ const since = this.cache.since();
390
+ const rows = (await ordered.toList()) as Record<string, unknown>[];
391
+ text = JSON.stringify(rows.map((row) => serialize(this.entity, row)));
392
+ await this.cache.write(key, field, text, since);
393
+ }
394
+ const list = JSON.parse(text) as string[];
395
+ return list.map((item) => deserialize(this.entity, item) as RowOf<S, K>);
396
+ }
397
+
398
+ /** 在缓存的整表上分页(page 从 1 开始) */
399
+ async toPage(page: number, size: number): Promise<Page<RowOf<S, K>>> {
400
+ const rows = await this.toList();
401
+ return { rows: rows.slice((page - 1) * size, page * size), total: rows.length, page, size };
402
+ }
403
+ }
404
+
405
+ /** 一行按列的编码写成文字(日期、大整数、二进制都能原样读回) */
406
+ function serialize(entity: Entity, row: Readonly<Record<string, unknown>>): string {
407
+ const flat = flattenRow(entity, row);
408
+ const encoded = Object.fromEntries(
409
+ Object.entries(entity.columns).map(([p, column]) => {
410
+ const value = flat[p];
411
+ return [
412
+ p,
413
+ value === null || value === undefined ? null : column.codec.encode(value as never),
414
+ ];
415
+ }),
416
+ );
417
+ return JSON.stringify(encoded, (_key, value: unknown) => {
418
+ if (typeof value === 'bigint') return { $bigint: value.toString() };
419
+ if (value instanceof Uint8Array) return { $bytes: Buffer.from(value).toString('base64') };
420
+ return value;
421
+ });
422
+ }
423
+
424
+ function deserialize(entity: Entity, text: string): Record<string, unknown> {
425
+ const raw = JSON.parse(text, (_key, value: unknown) => {
426
+ if (value && typeof value === 'object') {
427
+ const tagged = value as { $bigint?: string; $bytes?: string };
428
+ if (tagged.$bigint !== undefined) return BigInt(tagged.$bigint);
429
+ if (tagged.$bytes !== undefined) return new Uint8Array(Buffer.from(tagged.$bytes, 'base64'));
430
+ }
431
+ return value;
432
+ }) as Record<string, unknown>;
433
+ const flat = Object.fromEntries(
434
+ Object.entries(entity.columns).map(([p, column]) => {
435
+ const value = raw[p];
436
+ return [p, value === null || value === undefined ? null : column.codec.decode(value)];
437
+ }),
438
+ );
439
+ return nestRow(flat);
440
+ }
441
+
442
+ /** 条件的指纹:SQL 与参数(cyrb53,够用来区分缓存字段) */
443
+ function fingerprint(sql: string, params: readonly unknown[]): string {
444
+ const text = JSON.stringify([sql, params], (_k, v: unknown) =>
445
+ typeof v === 'bigint' ? v.toString() : v,
446
+ );
447
+ let h1 = 0xdeadbeef;
448
+ let h2 = 0x41c6ce57;
449
+ for (let i = 0; i < text.length; i++) {
450
+ const c = text.charCodeAt(i);
451
+ h1 = Math.imul(h1 ^ c, 2654435761);
452
+ h2 = Math.imul(h2 ^ c, 1597334677);
453
+ }
454
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
455
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
456
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
457
+ }
package/src/stores.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * 缓存的存储。三种部署各用一种:
3
+ *
4
+ * createCache({ entities }); // 单实例:默认的 memoryStore,本机缓存
5
+ * createCache({ entities, store: redisStore }); // 多实例:只用外部 Redis,实现 CacheStore
6
+ * createCache({ // 多实例:本机 + 外部 Redis,再加失效通知
7
+ * entities,
8
+ * store: tieredStore({ local: memoryStore(), shared: redisStore }),
9
+ * transport,
10
+ * });
11
+ *
12
+ * 本库不带 Redis 驱动,CacheStore 由使用方按自己的客户端实现(完整示例在仓库的 packages/test/src/redis-cache.ts)。
13
+ */
14
+
15
+ /**
16
+ * 缓存的存储(对应官方的 CacheManager):一个键下多个字段(字段是查询条件),按键删除。
17
+ * Redis 用 HGET / HSET + PEXPIRE / DEL 实现
18
+ */
19
+ export interface CacheStore {
20
+ /** 读一个字段;没有或过期时返回 undefined */
21
+ get(key: string, field: string): Promise<string | undefined>;
22
+ /** 写一个字段,ttlMs 毫秒后过期 */
23
+ set(key: string, field: string, value: string, ttlMs: number): Promise<void>;
24
+ /** 删掉这些键(连同它们所有的字段) */
25
+ delete(keys: readonly string[]): Promise<void>;
26
+ /**
27
+ * 别的实例的失效通知到了:只删本机这一层(共享层写入的那个实例已经删过);keys 为 undefined 时清空本机层(通知断过)。
28
+ * 没有这个方法的存储收到通知时按 delete 删
29
+ */
30
+ evictLocal?(keys: readonly string[] | undefined): Promise<void>;
31
+ }
32
+
33
+ /** memoryStore 的选项 */
34
+ export interface MemoryStoreOptions {
35
+ /** 最多存多少个键,默认 10000:超出时删最久没用过的。过期的键没人再读就不会被清掉,靠这个上限兜底 */
36
+ readonly maxKeys?: number;
37
+ /** 当前时间,可以换成测试用的时钟 */
38
+ readonly now?: () => number;
39
+ }
40
+
41
+ /** 进程内的存储:每个字段各自过期,键数有上限 */
42
+ export function memoryStore(options: MemoryStoreOptions = {}): CacheStore {
43
+ const { maxKeys = 10_000, now = Date.now } = options;
44
+ // Map 按插入顺序遍历:用过的键挪到末尾,最前面的就是最久没用过的
45
+ const keys = new Map<string, Map<string, { value: string; expires: number }>>();
46
+ const touch = (key: string, fields: Map<string, { value: string; expires: number }>) => {
47
+ keys.delete(key);
48
+ keys.set(key, fields);
49
+ };
50
+ const store: CacheStore = {
51
+ get(key, field) {
52
+ const fields = keys.get(key);
53
+ const item = fields?.get(field);
54
+ if (!fields || !item) return Promise.resolve(undefined);
55
+ if (item.expires <= now()) {
56
+ fields.delete(field);
57
+ return Promise.resolve(undefined);
58
+ }
59
+ touch(key, fields);
60
+ return Promise.resolve(item.value);
61
+ },
62
+ set(key, field, value, ttlMs) {
63
+ const fields = keys.get(key) ?? new Map<string, { value: string; expires: number }>();
64
+ fields.set(field, { value, expires: now() + ttlMs });
65
+ touch(key, fields);
66
+ if (keys.size > maxKeys) keys.delete(keys.keys().next().value!);
67
+ return Promise.resolve();
68
+ },
69
+ delete(list) {
70
+ for (const key of list) keys.delete(key);
71
+ return Promise.resolve();
72
+ },
73
+ evictLocal(list) {
74
+ if (list) return store.delete(list);
75
+ keys.clear();
76
+ return Promise.resolve();
77
+ },
78
+ };
79
+ return store;
80
+ }
81
+
82
+ /** tieredStore 的选项 */
83
+ export interface TieredStoreOptions {
84
+ /** 本机层,通常是 memoryStore() */
85
+ readonly local: CacheStore;
86
+ /** 共享层:使用方实现的 Redis 存储 */
87
+ readonly shared: CacheStore;
88
+ /** 本机层最多存多久(毫秒),默认 30 秒:失效通知丢了时本机层最多旧这么久 */
89
+ readonly localTtlMs?: number;
90
+ }
91
+
92
+ /**
93
+ * 两级存储:读先本机、再共享,共享命中时回填本机;写两层都写(本机层的过期时间取较短的);删两层都删;
94
+ * 别的实例的失效通知只删本机层
95
+ */
96
+ export function tieredStore(options: TieredStoreOptions): CacheStore {
97
+ const { local, shared, localTtlMs = 30_000 } = options;
98
+ return {
99
+ async get(key, field) {
100
+ const near = await local.get(key, field);
101
+ if (near !== undefined) return near;
102
+ const far = await shared.get(key, field);
103
+ if (far !== undefined) await local.set(key, field, far, localTtlMs);
104
+ return far;
105
+ },
106
+ async set(key, field, value, ttlMs) {
107
+ await shared.set(key, field, value, ttlMs);
108
+ await local.set(key, field, value, Math.min(ttlMs, localTtlMs));
109
+ },
110
+ async delete(keys) {
111
+ await shared.delete(keys);
112
+ await local.delete(keys);
113
+ },
114
+ async evictLocal(keys) {
115
+ if (local.evictLocal) await local.evictLocal(keys);
116
+ else if (keys) await local.delete(keys);
117
+ },
118
+ };
119
+ }