metal-orm 1.0.118 → 1.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metal-orm",
3
- "version": "1.0.118",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "types": "./dist/index.d.ts",
6
6
  "engines": {
@@ -43,7 +43,8 @@
43
43
  "mysql2": "^3.9.0",
44
44
  "pg": "^8.0.0",
45
45
  "sqlite3": "^5.1.6",
46
- "tedious": "^19.0.0"
46
+ "tedious": "^19.0.0",
47
+ "keyv": "^5.6.0"
47
48
  },
48
49
  "peerDependenciesMeta": {
49
50
  "mysql2": {
@@ -57,6 +58,9 @@
57
58
  },
58
59
  "tedious": {
59
60
  "optional": true
61
+ },
62
+ "keyv": {
63
+ "optional": true
60
64
  }
61
65
  },
62
66
  "devDependencies": {
@@ -66,6 +70,7 @@
66
70
  "@vitest/ui": "^4.0.18",
67
71
  "eslint": "^9.39.2",
68
72
  "express": "^5.2.1",
73
+ "keyv": "^5.6.0",
69
74
  "mysql-memory-server": "^1.14.0",
70
75
  "mysql2": "^3.16.2",
71
76
  "pg": "^8.17.2",
@@ -0,0 +1,2 @@
1
+ export { MemoryCacheAdapter } from './memory-cache-adapter.js';
2
+ export { KeyvCacheAdapter } from './keyv-cache-adapter.js';
@@ -0,0 +1,81 @@
1
+ import type { CacheProvider } from '../cache-interfaces.js';
2
+
3
+ /**
4
+ * Interface mínima para Keyv
5
+ * Usa unknown para permitir diferentes versões do Keyv
6
+ */
7
+ interface KeyvInstance {
8
+ get<T>(key: string): Promise<T | undefined>;
9
+ set<T>(key: string, value: T, ttl?: number): Promise<boolean | void>;
10
+ delete(key: string): Promise<boolean>;
11
+ iterator?: unknown;
12
+ disconnect?(): Promise<void>;
13
+ }
14
+
15
+ /**
16
+ * Adapter para Keyv (Redis, SQLite, etc.)
17
+ * Keyv deve ser instalado separadamente:
18
+ * npm install keyv @keyv/redis
19
+ */
20
+ export class KeyvCacheAdapter implements CacheProvider {
21
+ readonly name = 'keyv';
22
+
23
+ constructor(private keyv: KeyvInstance) {}
24
+
25
+ async get<T>(key: string): Promise<T | undefined> {
26
+ return this.keyv.get(key);
27
+ }
28
+
29
+ async has(key: string): Promise<boolean> {
30
+ const value = await this.keyv.get(key);
31
+ return value !== undefined;
32
+ }
33
+
34
+ async set<T>(key: string, value: T, ttlMs?: number): Promise<void> {
35
+ await this.keyv.set(key, value, ttlMs);
36
+ }
37
+
38
+ async delete(key: string): Promise<void> {
39
+ await this.keyv.delete(key);
40
+ }
41
+
42
+ async invalidate(key: string): Promise<void> {
43
+ await this.delete(key);
44
+ }
45
+
46
+ async invalidateTags(_tags: string[]): Promise<void> {
47
+ // Keyv não suporta invalidação por tags nativamente
48
+ // Para suporte completo, usar RedisTagProvider
49
+ throw new Error(
50
+ 'Keyv adapter does not support tag invalidation. ' +
51
+ 'Use MemoryCacheAdapter for testing or implement a custom Redis provider.'
52
+ );
53
+ }
54
+
55
+ async invalidatePrefix(prefix: string): Promise<void> {
56
+ // Tenta usar iterador se disponível (Redis)
57
+ if (typeof this.keyv.iterator === 'function') {
58
+ const keys: string[] = [];
59
+ for await (const [key] of this.keyv.iterator()) {
60
+ if (key.startsWith(prefix)) {
61
+ keys.push(key);
62
+ }
63
+ }
64
+
65
+ if (keys.length > 0) {
66
+ await Promise.all(keys.map(k => this.keyv.delete(k)));
67
+ }
68
+ return;
69
+ }
70
+
71
+ // Fallback: não suportado
72
+ throw new Error(
73
+ 'Keyv adapter does not support prefix invalidation in this store. ' +
74
+ 'Consider using a store with iterator support.'
75
+ );
76
+ }
77
+
78
+ async dispose(): Promise<void> {
79
+ await this.keyv.disconnect?.();
80
+ }
81
+ }
@@ -0,0 +1,127 @@
1
+ import type { CacheProvider } from '../cache-interfaces.js';
2
+
3
+ interface CacheEntry<T> {
4
+ value: T;
5
+ expiresAt?: number;
6
+ }
7
+
8
+ /**
9
+ * Implementação em memória do cache provider
10
+ * Útil para testes e ambientes de desenvolvimento
11
+ */
12
+ export class MemoryCacheAdapter implements CacheProvider {
13
+ readonly name = 'memory';
14
+ private storage: Map<string, CacheEntry<unknown>> = new Map();
15
+ private tagIndex: Map<string, Set<string>> = new Map();
16
+
17
+ async get<T>(key: string): Promise<T | undefined> {
18
+ const entry = this.storage.get(key);
19
+
20
+ if (!entry) {
21
+ return undefined;
22
+ }
23
+
24
+ // Verifica expiração
25
+ if (entry.expiresAt && Date.now() > entry.expiresAt) {
26
+ await this.delete(key);
27
+ return undefined;
28
+ }
29
+
30
+ return entry.value as T;
31
+ }
32
+
33
+ async has(key: string): Promise<boolean> {
34
+ const value = await this.get(key);
35
+ return value !== undefined;
36
+ }
37
+
38
+ async set<T>(key: string, value: T, ttlMs?: number): Promise<void> {
39
+ const entry: CacheEntry<T> = {
40
+ value,
41
+ expiresAt: ttlMs ? Date.now() + ttlMs : undefined,
42
+ };
43
+
44
+ this.storage.set(key, entry);
45
+ }
46
+
47
+ async delete(key: string): Promise<void> {
48
+ this.storage.delete(key);
49
+ // Remove do índice de tags
50
+ for (const [tag, keys] of this.tagIndex) {
51
+ keys.delete(key);
52
+ if (keys.size === 0) {
53
+ this.tagIndex.delete(tag);
54
+ }
55
+ }
56
+ }
57
+
58
+ async invalidate(key: string): Promise<void> {
59
+ await this.delete(key);
60
+ }
61
+
62
+ async invalidateTags(tags: string[]): Promise<void> {
63
+ const keysToDelete = new Set<string>();
64
+
65
+ for (const tag of tags) {
66
+ const keys = this.tagIndex.get(tag);
67
+ if (keys) {
68
+ for (const key of keys) {
69
+ keysToDelete.add(key);
70
+ }
71
+ this.tagIndex.delete(tag);
72
+ }
73
+ }
74
+
75
+ for (const key of keysToDelete) {
76
+ this.storage.delete(key);
77
+ }
78
+ }
79
+
80
+ async invalidatePrefix(prefix: string): Promise<void> {
81
+ const keysToDelete: string[] = [];
82
+
83
+ for (const key of this.storage.keys()) {
84
+ if (key.startsWith(prefix)) {
85
+ keysToDelete.push(key);
86
+ }
87
+ }
88
+
89
+ for (const key of keysToDelete) {
90
+ await this.delete(key);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Registra uma chave com tags (para invalidação)
96
+ */
97
+ registerTags(key: string, tags: string[]): void {
98
+ for (const tag of tags) {
99
+ if (!this.tagIndex.has(tag)) {
100
+ this.tagIndex.set(tag, new Set());
101
+ }
102
+ this.tagIndex.get(tag)!.add(key);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Limpa todo o cache
108
+ */
109
+ clear(): void {
110
+ this.storage.clear();
111
+ this.tagIndex.clear();
112
+ }
113
+
114
+ /**
115
+ * Retorna estatísticas do cache
116
+ */
117
+ getStats(): { size: number; tags: number } {
118
+ return {
119
+ size: this.storage.size,
120
+ tags: this.tagIndex.size,
121
+ };
122
+ }
123
+
124
+ async dispose(): Promise<void> {
125
+ this.clear();
126
+ }
127
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Interfaces segregadas para cache (ISP - Interface Segregation Principle)
3
+ */
4
+
5
+ /**
6
+ * Leitura de cache - pode ser implementada por read-replicas
7
+ */
8
+ export interface CacheReader {
9
+ get<T>(key: string): Promise<T | undefined>;
10
+ has(key: string): Promise<boolean>;
11
+ }
12
+
13
+ /**
14
+ * Escrita em cache - pode ser implementada por write-nodes
15
+ */
16
+ export interface CacheWriter {
17
+ set<T>(key: string, value: T, ttlMs?: number): Promise<void>;
18
+ delete(key: string): Promise<void>;
19
+ }
20
+
21
+ /**
22
+ * Invalidação em massa - separada pois nem todo cache suporta tags
23
+ */
24
+ export interface CacheInvalidator {
25
+ invalidate(key: string): Promise<void>;
26
+ invalidateTags(tags: string[]): Promise<void>;
27
+ invalidatePrefix(prefix: string): Promise<void>;
28
+ }
29
+
30
+ /**
31
+ * Interface completa para implementações full-featured
32
+ */
33
+ export interface CacheProvider extends CacheReader, CacheWriter, CacheInvalidator {
34
+ readonly name: string;
35
+ dispose?(): Promise<void>;
36
+ }
37
+
38
+ /**
39
+ * TTL human-readable: '1d', '2h', '30m', '15s', '1w'
40
+ * Ou número em milissegundos
41
+ */
42
+ export type Duration = number | `${number}${'s'|'m'|'h'|'d'|'w'}`;
43
+
44
+ /**
45
+ * Opções de cache para uma query
46
+ */
47
+ export interface CacheOptions {
48
+ key: string;
49
+ ttl: Duration;
50
+ tags?: string[];
51
+ autoInvalidate?: boolean;
52
+ condition?: (result: unknown) => boolean;
53
+ }
54
+
55
+ /**
56
+ * Estratégias de invalidação disponíveis
57
+ */
58
+ export type InvalidationStrategy =
59
+ | 'tags'
60
+ | 'entity'
61
+ | 'prefix'
62
+ | 'key'
63
+ | 'ttl';
64
+
65
+ /**
66
+ * Estado interno do cache no query builder
67
+ */
68
+ export interface CacheState {
69
+ options?: CacheOptions;
70
+ }
@@ -0,0 +1,82 @@
1
+ import type { Duration } from './cache-interfaces.js';
2
+
3
+ /**
4
+ * Multiplicadores para converter unidades de tempo em milissegundos
5
+ */
6
+ const DURATION_MULTIPLIERS = {
7
+ s: 1000, // segundos
8
+ m: 60000, // minutos
9
+ h: 3600000, // horas
10
+ d: 86400000, // dias
11
+ w: 604800000, // semanas
12
+ } as const;
13
+
14
+ /**
15
+ * Parse de duração human-readable para milissegundos
16
+ * @param duration - Número (ms) ou string no formato '30s', '10m', '2h', '1d', '1w'
17
+ * @returns Tempo em milissegundos
18
+ * @throws Error se o formato for inválido
19
+ *
20
+ * @example
21
+ * parseDuration(60000) // 60000
22
+ * parseDuration('30s') // 30000
23
+ * parseDuration('10m') // 600000
24
+ * parseDuration('2h') // 7200000
25
+ * parseDuration('1d') // 86400000
26
+ * parseDuration('1w') // 604800000
27
+ */
28
+ export function parseDuration(duration: Duration): number {
29
+ // Se já é número, retorna diretamente
30
+ if (typeof duration === 'number') {
31
+ return duration;
32
+ }
33
+
34
+ // Regex para extrair número e unidade
35
+ const match = duration.match(/^(\d+)([smhdw])$/);
36
+
37
+ if (!match) {
38
+ throw new Error(
39
+ `Invalid duration format: "${duration}". ` +
40
+ `Use formats like '30s', '10m', '2h', '1d', '1w' or a number in milliseconds.`
41
+ );
42
+ }
43
+
44
+ const value = parseInt(match[1], 10);
45
+ const unit = match[2] as keyof typeof DURATION_MULTIPLIERS;
46
+
47
+ return value * DURATION_MULTIPLIERS[unit];
48
+ }
49
+
50
+ /**
51
+ * Converte milissegundos para formato human-readable
52
+ * @param ms - Tempo em milissegundos
53
+ * @returns String no formato mais apropriado
54
+ *
55
+ * @example
56
+ * formatDuration(30000) // '30s'
57
+ * formatDuration(600000) // '10m'
58
+ * formatDuration(7200000) // '2h'
59
+ */
60
+ export function formatDuration(ms: number): string {
61
+ if (ms < 1000) return `${ms}ms`;
62
+ if (ms < 60000) return `${Math.floor(ms / 1000)}s`;
63
+ if (ms < 3600000) return `${Math.floor(ms / 60000)}m`;
64
+ if (ms < 86400000) return `${Math.floor(ms / 3600000)}h`;
65
+ if (ms < 604800000) return `${Math.floor(ms / 86400000)}d`;
66
+ return `${Math.floor(ms / 604800000)}w`;
67
+ }
68
+
69
+ /**
70
+ * Valida se uma string é uma duração válida
71
+ * @param value - Valor a ser validado
72
+ * @returns true se for uma duração válida
73
+ */
74
+ export function isValidDuration(value: unknown): value is Duration {
75
+ if (typeof value === 'number') {
76
+ return value >= 0;
77
+ }
78
+ if (typeof value === 'string') {
79
+ return /^\d+[smhdw]$/.test(value);
80
+ }
81
+ return false;
82
+ }
@@ -0,0 +1,28 @@
1
+ // Interfaces
2
+ export type {
3
+ CacheReader,
4
+ CacheWriter,
5
+ CacheInvalidator,
6
+ CacheProvider,
7
+ Duration,
8
+ CacheOptions,
9
+ InvalidationStrategy,
10
+ CacheState,
11
+ } from './cache-interfaces.js';
12
+
13
+ // Utils
14
+ export { parseDuration, formatDuration, isValidDuration } from './duration-utils.js';
15
+
16
+ // Strategies
17
+ export type { CacheStrategy } from './strategies/cache-strategy.js';
18
+ export { DefaultCacheStrategy } from './strategies/default-cache-strategy.js';
19
+
20
+ // Adapters
21
+ export { MemoryCacheAdapter } from './adapters/memory-cache-adapter.js';
22
+ export { KeyvCacheAdapter } from './adapters/keyv-cache-adapter.js';
23
+
24
+ // Manager
25
+ export { QueryCacheManager } from './query-cache-manager.js';
26
+
27
+ // Tag Index
28
+ export { TagIndex } from './tag-index.js';
@@ -0,0 +1,130 @@
1
+ import type {
2
+ CacheProvider,
3
+ CacheOptions,
4
+ Duration
5
+ } from './cache-interfaces.js';
6
+ import type { CacheStrategy } from './strategies/cache-strategy.js';
7
+ import { DefaultCacheStrategy } from './strategies/default-cache-strategy.js';
8
+ import { parseDuration } from './duration-utils.js';
9
+ import { MemoryCacheAdapter } from './adapters/memory-cache-adapter.js';
10
+
11
+ /**
12
+ * Gerenciador de cache para queries
13
+ * Responsabilidade única: orquestrar leitura/escrita no cache (SRP)
14
+ */
15
+ export class QueryCacheManager {
16
+ constructor(
17
+ private provider: CacheProvider = new MemoryCacheAdapter(),
18
+ private strategy: CacheStrategy = new DefaultCacheStrategy(),
19
+ private defaultTtl: Duration = '1h'
20
+ ) {}
21
+
22
+ /**
23
+ * Executa com cache - padrão execute-around
24
+ * @returns Resultado da execução (do cache ou da função)
25
+ */
26
+ async getOrExecute<T>(
27
+ options: CacheOptions,
28
+ executor: () => Promise<T>,
29
+ tenantId?: string | number
30
+ ): Promise<T> {
31
+ const key = this.strategy.generateKey(options.key, tenantId);
32
+ const ttlMs = this.parseDuration(options.ttl ?? this.defaultTtl);
33
+
34
+ // Tenta obter do cache
35
+ const cached = await this.provider.get<T>(key);
36
+ if (cached !== undefined) {
37
+ return this.strategy.deserialize(cached);
38
+ }
39
+
40
+ // Executa a query
41
+ const result = await executor();
42
+
43
+ // Verifica se deve cachear
44
+ if (!this.strategy.shouldCache(result, options)) {
45
+ return result;
46
+ }
47
+
48
+ // Serializa e salva no cache
49
+ const serialized = this.strategy.serialize(result);
50
+ await this.provider.set(key, serialized, ttlMs);
51
+
52
+ // Registra tags se disponível
53
+ if (options.tags) {
54
+ await this.registerTags(key, options.tags);
55
+ }
56
+
57
+ return result;
58
+ }
59
+
60
+ /**
61
+ * Invalida uma chave específica
62
+ */
63
+ async invalidateKey(key: string, tenantId?: string | number): Promise<void> {
64
+ const fullKey = this.strategy.generateKey(key, tenantId);
65
+ await this.provider.invalidate(fullKey);
66
+ }
67
+
68
+ /**
69
+ * Invalida por tags
70
+ */
71
+ async invalidateTags(tags: string[]): Promise<void> {
72
+ await this.provider.invalidateTags(tags);
73
+ }
74
+
75
+ /**
76
+ * Invalida por prefixo (útil para multi-tenancy)
77
+ */
78
+ async invalidatePrefix(prefix: string): Promise<void> {
79
+ await this.provider.invalidatePrefix(prefix);
80
+ }
81
+
82
+ /**
83
+ * Limpa todo o cache (cuidado!)
84
+ */
85
+ async clear(): Promise<void> {
86
+ const provider = this.provider as CacheProvider & { clear?: () => void };
87
+ if (typeof provider.clear === 'function') {
88
+ provider.clear();
89
+ } else {
90
+ throw new Error('Cache provider does not support clear operation');
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Retorna estatísticas do cache (se disponível)
96
+ */
97
+ getStats(): { size: number; tags: number } | undefined {
98
+ const provider = this.provider as CacheProvider & { getStats?: () => { size: number; tags: number } };
99
+ if (typeof provider.getStats === 'function') {
100
+ return provider.getStats();
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ /**
106
+ * Libera recursos do cache
107
+ */
108
+ async dispose(): Promise<void> {
109
+ await this.provider.dispose?.();
110
+ }
111
+
112
+ /**
113
+ * Registra tags para uma chave
114
+ */
115
+ private async registerTags(key: string, tags: string[]): Promise<void> {
116
+ // Se o provider tem suporte nativo a tags
117
+ const provider = this.provider as CacheProvider & { registerTags?: (key: string, tags: string[]) => void };
118
+ if (typeof provider.registerTags === 'function') {
119
+ provider.registerTags(key, tags);
120
+ }
121
+ // Caso contrário, as tags são usadas apenas na invalidação
122
+ }
123
+
124
+ /**
125
+ * Converte duração para milissegundos
126
+ */
127
+ private parseDuration(d: Duration): number {
128
+ return parseDuration(d);
129
+ }
130
+ }
@@ -0,0 +1,29 @@
1
+ import type { CacheOptions } from '../cache-interfaces.js';
2
+
3
+ /**
4
+ * Estratégia de cache - define como chaves são geradas,
5
+ * dados são serializados/desserializados e se devem ser cacheados
6
+ */
7
+ export interface CacheStrategy {
8
+ readonly name: string;
9
+
10
+ /**
11
+ * Gera chave de cache considerando tenant
12
+ */
13
+ generateKey(queryKey: string, tenantId?: string | number): string;
14
+
15
+ /**
16
+ * Decide se o resultado deve ser cacheado
17
+ */
18
+ shouldCache(result: unknown, options: CacheOptions): boolean;
19
+
20
+ /**
21
+ * Serializa dados para armazenamento
22
+ */
23
+ serialize<T>(data: T): unknown;
24
+
25
+ /**
26
+ * Desserializa dados do cache
27
+ */
28
+ deserialize<T>(data: unknown): T;
29
+ }
@@ -0,0 +1,96 @@
1
+ import type { CacheStrategy } from './cache-strategy.js';
2
+ import type { CacheOptions } from '../cache-interfaces.js';
3
+
4
+ /**
5
+ * Implementação padrão da estratégia de cache
6
+ * Suporta serialização de Date, BigInt e multi-tenancy
7
+ */
8
+ export class DefaultCacheStrategy implements CacheStrategy {
9
+ readonly name = 'default';
10
+
11
+ /**
12
+ * Gera chave de cache com prefixo de tenant se houver
13
+ */
14
+ generateKey(queryKey: string, tenantId?: string | number): string {
15
+ if (tenantId !== undefined) {
16
+ return `tenant:${tenantId}:${queryKey}`;
17
+ }
18
+ return queryKey;
19
+ }
20
+
21
+ /**
22
+ * Verifica se deve cachear baseado na condição configurada
23
+ */
24
+ shouldCache(result: unknown, options: CacheOptions): boolean {
25
+ if (options.condition) {
26
+ return options.condition(result);
27
+ }
28
+ return true;
29
+ }
30
+
31
+ /**
32
+ * Serializa com suporte a tipos especiais
33
+ */
34
+ serialize<T>(data: T): unknown {
35
+ return JSON.stringify(data, (key, value) => {
36
+ // Serializa Date
37
+ if (value instanceof Date) {
38
+ return { __type: 'Date', value: value.toISOString() };
39
+ }
40
+
41
+ // Serializa BigInt
42
+ if (typeof value === 'bigint') {
43
+ return { __type: 'BigInt', value: value.toString() };
44
+ }
45
+
46
+ // Serializa Map
47
+ if (value instanceof Map) {
48
+ return { __type: 'Map', value: Array.from(value.entries()) };
49
+ }
50
+
51
+ // Serializa Set
52
+ if (value instanceof Set) {
53
+ return { __type: 'Set', value: Array.from(value) };
54
+ }
55
+
56
+ return value;
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Desserializa restaurando tipos especiais
62
+ */
63
+ deserialize<T>(data: unknown): T {
64
+ if (typeof data !== 'string') {
65
+ return data as T;
66
+ }
67
+
68
+ return JSON.parse(data, (key, value) => {
69
+ if (!value || typeof value !== 'object') {
70
+ return value;
71
+ }
72
+
73
+ // Restaura Date
74
+ if (value.__type === 'Date') {
75
+ return new Date(value.value);
76
+ }
77
+
78
+ // Restaura BigInt
79
+ if (value.__type === 'BigInt') {
80
+ return BigInt(value.value);
81
+ }
82
+
83
+ // Restaura Map
84
+ if (value.__type === 'Map') {
85
+ return new Map(value.value);
86
+ }
87
+
88
+ // Restaura Set
89
+ if (value.__type === 'Set') {
90
+ return new Set(value.value);
91
+ }
92
+
93
+ return value;
94
+ }) as T;
95
+ }
96
+ }
@@ -0,0 +1,2 @@
1
+ export type { CacheStrategy } from './cache-strategy.js';
2
+ export { DefaultCacheStrategy } from './default-cache-strategy.js';