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.
- package/README.md +26 -14
- package/dist/index.cjs +728 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +419 -7
- package/dist/index.d.ts +419 -7
- package/dist/index.js +720 -17
- package/dist/index.js.map +1 -1
- package/package.json +7 -2
- package/scripts/generate-entities/cli.mjs +7 -0
- package/scripts/generate-entities/generate.mjs +10 -6
- package/scripts/inspect-schema.mjs +181 -0
- package/scripts/naming-strategy.mjs +17 -7
- package/src/cache/adapters/index.ts +2 -0
- package/src/cache/adapters/keyv-cache-adapter.ts +81 -0
- package/src/cache/adapters/memory-cache-adapter.ts +127 -0
- package/src/cache/cache-interfaces.ts +70 -0
- package/src/cache/duration-utils.ts +82 -0
- package/src/cache/index.ts +28 -0
- package/src/cache/query-cache-manager.ts +130 -0
- package/src/cache/strategies/cache-strategy.ts +29 -0
- package/src/cache/strategies/default-cache-strategy.ts +96 -0
- package/src/cache/strategies/index.ts +2 -0
- package/src/cache/tag-index.ts +128 -0
- package/src/core/dialect/abstract.ts +565 -565
- package/src/core/dialect/mssql/index.ts +68 -3
- package/src/core/dialect/postgres/index.ts +1 -1
- package/src/core/dialect/sqlite/index.ts +1 -1
- package/src/core/execution/db-executor.ts +107 -103
- package/src/core/execution/executors/mysql-executor.ts +9 -2
- package/src/index.ts +3 -0
- package/src/orm/orm-session.ts +616 -563
- package/src/orm/orm.ts +108 -71
- package/src/orm/unit-of-work.ts +22 -4
- package/src/query-builder/select/cache-facet.ts +67 -0
- 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.
|
|
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
|
-
|
|
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,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
|
+
}
|