metal-orm 1.0.118 → 1.1.1

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.
Files changed (35) hide show
  1. package/README.md +26 -14
  2. package/dist/index.cjs +728 -17
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +419 -7
  5. package/dist/index.d.ts +419 -7
  6. package/dist/index.js +720 -17
  7. package/dist/index.js.map +1 -1
  8. package/package.json +7 -2
  9. package/scripts/generate-entities/cli.mjs +7 -0
  10. package/scripts/generate-entities/generate.mjs +10 -6
  11. package/scripts/inspect-schema.mjs +181 -0
  12. package/scripts/naming-strategy.mjs +17 -7
  13. package/src/cache/adapters/index.ts +2 -0
  14. package/src/cache/adapters/keyv-cache-adapter.ts +81 -0
  15. package/src/cache/adapters/memory-cache-adapter.ts +127 -0
  16. package/src/cache/cache-interfaces.ts +70 -0
  17. package/src/cache/duration-utils.ts +82 -0
  18. package/src/cache/index.ts +28 -0
  19. package/src/cache/query-cache-manager.ts +130 -0
  20. package/src/cache/strategies/cache-strategy.ts +29 -0
  21. package/src/cache/strategies/default-cache-strategy.ts +96 -0
  22. package/src/cache/strategies/index.ts +2 -0
  23. package/src/cache/tag-index.ts +128 -0
  24. package/src/core/dialect/abstract.ts +565 -565
  25. package/src/core/dialect/mssql/index.ts +68 -3
  26. package/src/core/dialect/postgres/index.ts +1 -1
  27. package/src/core/dialect/sqlite/index.ts +1 -1
  28. package/src/core/execution/db-executor.ts +107 -103
  29. package/src/core/execution/executors/mysql-executor.ts +9 -2
  30. package/src/index.ts +3 -0
  31. package/src/orm/orm-session.ts +616 -563
  32. package/src/orm/orm.ts +108 -71
  33. package/src/orm/unit-of-work.ts +22 -4
  34. package/src/query-builder/select/cache-facet.ts +67 -0
  35. package/src/query-builder/select.ts +125 -57
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metal-orm",
3
- "version": "1.0.118",
3
+ "version": "1.1.1",
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",
@@ -200,6 +200,13 @@ Flags:
200
200
  --out=<file> Override the generated file (defaults to generated-entities.ts or the index inside --out-dir)
201
201
  --out-dir=<dir> Emit one file per entity inside this directory plus the shared index
202
202
  --help Show this help
203
+
204
+ Naming Overrides JSON format:
205
+ {
206
+ "irregulars": { "person": "people", "child": "children" },
207
+ "relationOverrides": { "User": { "orders": "purchases" } },
208
+ "classNameOverrides": { "users_table": "UserAccount", "order_items": "OrderLineItem" }
209
+ }
203
210
  `
204
211
  );
205
212
  };
@@ -18,8 +18,8 @@ const loadNamingOverrides = async (filePath, fsPromises) => {
18
18
  throw new Error(`Naming overrides at ${filePath} must be an object`);
19
19
  }
20
20
 
21
- // Support both flat format { "singular": "plural" } and structured { irregulars: {...}, relationOverrides: {...} }
22
- const hasStructuredFormat = parsed.irregulars || parsed.relationOverrides;
21
+ // Support both flat format { "singular": "plural" } and structured { irregulars: {...}, relationOverrides: {...}, classNameOverrides: {...} }
22
+ const hasStructuredFormat = parsed.irregulars || parsed.relationOverrides || parsed.classNameOverrides;
23
23
 
24
24
  const irregulars = hasStructuredFormat
25
25
  ? (parsed.irregulars && typeof parsed.irregulars === 'object' ? parsed.irregulars : {})
@@ -29,15 +29,19 @@ const loadNamingOverrides = async (filePath, fsPromises) => {
29
29
  ? parsed.relationOverrides
30
30
  : {};
31
31
 
32
- return { irregulars, relationOverrides };
32
+ const classNameOverrides = hasStructuredFormat && parsed.classNameOverrides && typeof parsed.classNameOverrides === 'object'
33
+ ? parsed.classNameOverrides
34
+ : {};
35
+
36
+ return { irregulars, relationOverrides, classNameOverrides };
33
37
  };
34
38
 
35
39
  export const generateEntities = async (opts, context = {}) => {
36
40
  const { fs: fsPromises = fs, logger = console } = context;
37
- const { irregulars, relationOverrides } = opts.namingOverrides
41
+ const { irregulars, relationOverrides, classNameOverrides } = opts.namingOverrides
38
42
  ? await loadNamingOverrides(opts.namingOverrides, fsPromises)
39
- : { irregulars: undefined, relationOverrides: {} };
40
- const naming = createNamingStrategy(opts.locale, irregulars, relationOverrides);
43
+ : { irregulars: undefined, relationOverrides: {}, classNameOverrides: {} };
44
+ const naming = createNamingStrategy(opts.locale, irregulars, relationOverrides, classNameOverrides);
41
45
 
42
46
  const { executor, cleanup } = await loadDriver(opts.dialect, opts.url, opts.dbPath);
43
47
  let schema;
@@ -0,0 +1,181 @@
1
+ import { Connection, Request } from 'tedious';
2
+
3
+ const REQUIRED_ENV = ['PGE_DIGITAL_HOST', 'PGE_DIGITAL_USER', 'PGE_DIGITAL_PASSWORD'];
4
+
5
+ const hasDbEnv = REQUIRED_ENV.every((name) => !!process.env[name]);
6
+
7
+ if (!hasDbEnv) {
8
+ console.error('Missing required environment variables:', REQUIRED_ENV.filter(n => !process.env[n]));
9
+ process.exit(1);
10
+ }
11
+
12
+ const parseBool = (value, fallback) => {
13
+ if (value === undefined) return fallback;
14
+ return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
15
+ };
16
+
17
+ async function queryTableSchema(connection, tableName) {
18
+ return new Promise((resolve, reject) => {
19
+ const query = `
20
+ SELECT
21
+ c.COLUMN_NAME,
22
+ c.DATA_TYPE,
23
+ c.IS_NULLABLE,
24
+ c.CHARACTER_MAXIMUM_LENGTH,
25
+ c.NUMERIC_PRECISION,
26
+ c.NUMERIC_SCALE,
27
+ COLUMNPROPERTY(OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME), c.COLUMN_NAME, 'IsIdentity') as IS_IDENTITY,
28
+ CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END as IS_PRIMARY_KEY
29
+ FROM INFORMATION_SCHEMA.COLUMNS c
30
+ LEFT JOIN (
31
+ SELECT ku.TABLE_CATALOG, ku.TABLE_SCHEMA, ku.TABLE_NAME, ku.COLUMN_NAME
32
+ FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
33
+ JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku ON tc.CONSTRAINT_NAME = ku.CONSTRAINT_NAME
34
+ WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY'
35
+ ) pk ON c.TABLE_CATALOG = pk.TABLE_CATALOG
36
+ AND c.TABLE_SCHEMA = pk.TABLE_SCHEMA
37
+ AND c.TABLE_NAME = pk.TABLE_NAME
38
+ AND c.COLUMN_NAME = pk.COLUMN_NAME
39
+ WHERE c.TABLE_NAME = '${tableName}'
40
+ ORDER BY c.ORDINAL_POSITION
41
+ `;
42
+
43
+ const request = new Request(query, (err, rowCount) => {
44
+ if (err) reject(err);
45
+ });
46
+
47
+ const columns = [];
48
+ request.on('row', (columns_data) => {
49
+ const col = {};
50
+ columns_data.forEach((c) => {
51
+ col[c.metadata.colName] = c.value;
52
+ });
53
+ columns.push(col);
54
+ });
55
+
56
+ request.on('requestCompleted', () => {
57
+ resolve(columns);
58
+ });
59
+
60
+ connection.execSql(request);
61
+ });
62
+ }
63
+
64
+ async function queryForeignKeys(connection, tableName) {
65
+ return new Promise((resolve, reject) => {
66
+ const query = `
67
+ SELECT
68
+ fk.name AS FK_NAME,
69
+ tp.name AS PARENT_TABLE,
70
+ cp.name AS PARENT_COLUMN,
71
+ tr.name AS REFERENCED_TABLE,
72
+ cr.name AS REFERENCED_COLUMN
73
+ FROM sys.foreign_keys fk
74
+ INNER JOIN sys.tables tp ON fk.parent_object_id = tp.object_id
75
+ INNER JOIN sys.tables tr ON fk.referenced_object_id = tr.object_id
76
+ INNER JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id
77
+ INNER JOIN sys.columns cp ON fkc.parent_object_id = cp.object_id AND fkc.parent_column_id = cp.column_id
78
+ INNER JOIN sys.columns cr ON fkc.referenced_object_id = cr.object_id AND fkc.referenced_column_id = cr.column_id
79
+ WHERE tp.name = '${tableName}' OR tr.name = '${tableName}'
80
+ `;
81
+
82
+ const request = new Request(query, (err, rowCount) => {
83
+ if (err) reject(err);
84
+ });
85
+
86
+ const fks = [];
87
+ request.on('row', (columns) => {
88
+ const fk = {};
89
+ columns.forEach((c) => {
90
+ fk[c.metadata.colName] = c.value;
91
+ });
92
+ fks.push(fk);
93
+ });
94
+
95
+ request.on('requestCompleted', () => {
96
+ resolve(fks);
97
+ });
98
+
99
+ connection.execSql(request);
100
+ });
101
+ }
102
+
103
+ async function main() {
104
+ const { PGE_DIGITAL_HOST, PGE_DIGITAL_USER, PGE_DIGITAL_PASSWORD } = process.env;
105
+ const database = process.env.PGE_DIGITAL_DATABASE ?? 'PGE_DIGITAL';
106
+ const encrypt = parseBool(process.env.PGE_DIGITAL_ENCRYPT, true);
107
+ const trustServerCertificate = parseBool(process.env.PGE_DIGITAL_TRUST_CERT, true);
108
+ const port = Number(process.env.PGE_DIGITAL_PORT ?? '1433');
109
+
110
+ const tablesToInspect = ['carga', 'registro_tramitacao', 'tramitacao', 'usuario',
111
+ 'processo_administrativo', 'classificacao', 'especializada', 'acervo',
112
+ 'processo_judicial', 'parte', 'pessoa', 'tipo_polo'];
113
+
114
+ const connection = await new Promise((resolve, reject) => {
115
+ const conn = new Connection({
116
+ server: PGE_DIGITAL_HOST,
117
+ authentication: {
118
+ type: 'default',
119
+ options: {
120
+ userName: PGE_DIGITAL_USER,
121
+ password: PGE_DIGITAL_PASSWORD,
122
+ },
123
+ },
124
+ options: {
125
+ database,
126
+ encrypt,
127
+ trustServerCertificate,
128
+ port: Number.isFinite(port) ? port : 1433,
129
+ },
130
+ });
131
+
132
+ conn.on('connect', (err) => (err ? reject(err) : resolve(conn)));
133
+ conn.connect();
134
+ });
135
+
136
+ console.log(`Connected to ${database} on ${PGE_DIGITAL_HOST}\n`);
137
+
138
+ for (const tableName of tablesToInspect) {
139
+ try {
140
+ console.log(`\n=== Table: ${tableName} ===`);
141
+
142
+ const columns = await queryTableSchema(connection, tableName);
143
+ if (columns.length === 0) {
144
+ console.log(' Table not found or no columns');
145
+ continue;
146
+ }
147
+
148
+ console.log('Columns:');
149
+ columns.forEach(col => {
150
+ const nullable = col.IS_NULLABLE === 'YES' ? 'NULL' : 'NOT NULL';
151
+ const pk = col.IS_PRIMARY_KEY ? ' [PK]' : '';
152
+ const identity = col.IS_IDENTITY ? ' [IDENTITY]' : '';
153
+ let dataType = col.DATA_TYPE;
154
+ if (col.CHARACTER_MAXIMUM_LENGTH && col.CHARACTER_MAXIMUM_LENGTH > 0) {
155
+ dataType += `(${col.CHARACTER_MAXIMUM_LENGTH})`;
156
+ } else if (col.NUMERIC_PRECISION) {
157
+ dataType += `(${col.NUMERIC_PRECISION},${col.NUMERIC_SCALE || 0})`;
158
+ }
159
+ console.log(` ${col.COLUMN_NAME}: ${dataType} ${nullable}${pk}${identity}`);
160
+ });
161
+
162
+ const fks = await queryForeignKeys(connection, tableName);
163
+ if (fks.length > 0) {
164
+ console.log('Foreign Keys:');
165
+ fks.forEach(fk => {
166
+ console.log(` ${fk.PARENT_TABLE}.${fk.PARENT_COLUMN} -> ${fk.REFERENCED_TABLE}.${fk.REFERENCED_COLUMN}`);
167
+ });
168
+ }
169
+ } catch (err) {
170
+ console.error(`Error inspecting ${tableName}:`, err.message);
171
+ }
172
+ }
173
+
174
+ connection.close();
175
+ console.log('\n=== Done ===');
176
+ }
177
+
178
+ main().catch(err => {
179
+ console.error('Fatal error:', err);
180
+ process.exit(1);
181
+ });
@@ -1,11 +1,12 @@
1
1
  import { resolveInflector } from './inflection/index.mjs';
2
2
 
3
3
  export class BaseNamingStrategy {
4
- constructor(irregulars = {}, inflector = resolveInflector('en'), relationOverrides = {}) {
4
+ constructor(irregulars = {}, inflector = resolveInflector('en'), relationOverrides = {}, classNameOverrides = {}) {
5
5
  this.irregulars = new Map();
6
6
  this.inverseIrregulars = new Map();
7
7
  this.inflector = inflector;
8
8
  this.relationOverrides = new Map();
9
+ this.classNameOverrides = new Map();
9
10
 
10
11
  for (const [singular, plural] of Object.entries(irregulars)) {
11
12
  if (!singular || !plural) continue;
@@ -21,6 +22,12 @@ export class BaseNamingStrategy {
21
22
  if (!overrides || typeof overrides !== 'object') continue;
22
23
  this.relationOverrides.set(className, new Map(Object.entries(overrides)));
23
24
  }
25
+
26
+ // Build class name overrides map: tableName -> className
27
+ for (const [tableName, className] of Object.entries(classNameOverrides)) {
28
+ if (!tableName || !className) continue;
29
+ this.classNameOverrides.set(tableName, className);
30
+ }
24
31
  }
25
32
 
26
33
  applyRelationOverride(className, propertyName) {
@@ -80,6 +87,9 @@ export class BaseNamingStrategy {
80
87
  }
81
88
 
82
89
  classNameFromTable(tableName) {
90
+ if (this.classNameOverrides.has(tableName)) {
91
+ return this.classNameOverrides.get(tableName);
92
+ }
83
93
  return this.toPascalCase(this.singularize(tableName));
84
94
  }
85
95
 
@@ -113,20 +123,20 @@ export class BaseNamingStrategy {
113
123
  }
114
124
 
115
125
  export class EnglishNamingStrategy extends BaseNamingStrategy {
116
- constructor(irregulars = {}) {
117
- super(irregulars, resolveInflector('en'));
126
+ constructor(irregulars = {}, relationOverrides = {}, classNameOverrides = {}) {
127
+ super(irregulars, resolveInflector('en'), relationOverrides, classNameOverrides);
118
128
  }
119
129
  }
120
130
 
121
131
  export class PortugueseNamingStrategy extends BaseNamingStrategy {
122
- constructor(irregulars = {}) {
132
+ constructor(irregulars = {}, relationOverrides = {}, classNameOverrides = {}) {
123
133
  const inflector = resolveInflector('pt-BR');
124
- super({ ...inflector.defaultIrregulars, ...irregulars }, inflector);
134
+ super({ ...inflector.defaultIrregulars, ...irregulars }, inflector, relationOverrides, classNameOverrides);
125
135
  }
126
136
  }
127
137
 
128
- export const createNamingStrategy = (locale = 'en', irregulars, relationOverrides = {}) => {
138
+ export const createNamingStrategy = (locale = 'en', irregulars, relationOverrides = {}, classNameOverrides = {}) => {
129
139
  const inflector = resolveInflector(locale);
130
140
  const mergedIrregulars = { ...(inflector.defaultIrregulars || {}), ...(irregulars || {}) };
131
- return new BaseNamingStrategy(mergedIrregulars, inflector, relationOverrides);
141
+ return new BaseNamingStrategy(mergedIrregulars, inflector, relationOverrides, classNameOverrides);
132
142
  };
@@ -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
+ }