forge-sql-orm 2.1.24 → 2.1.26
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 +126 -23
- package/dist/core/ForgeSQLQueryBuilder.d.ts.map +1 -1
- package/dist/core/ForgeSQLQueryBuilder.js +38 -1
- package/dist/core/ForgeSQLQueryBuilder.js.map +1 -1
- package/dist/core/VectorTiDB.d.ts +120 -0
- package/dist/core/VectorTiDB.d.ts.map +1 -0
- package/dist/core/VectorTiDB.js +169 -0
- package/dist/core/VectorTiDB.js.map +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/utils/cacheUtils.d.ts +13 -1
- package/dist/utils/cacheUtils.d.ts.map +1 -1
- package/dist/utils/cacheUtils.js +60 -47
- package/dist/utils/cacheUtils.js.map +1 -1
- package/dist/webtriggers/clearCacheSchedulerTrigger.d.ts +24 -4
- package/dist/webtriggers/clearCacheSchedulerTrigger.d.ts.map +1 -1
- package/dist/webtriggers/clearCacheSchedulerTrigger.js +24 -4
- package/dist/webtriggers/clearCacheSchedulerTrigger.js.map +1 -1
- package/package.json +13 -13
- package/src/core/ForgeSQLQueryBuilder.ts +48 -5
- package/src/core/VectorTiDB.ts +192 -0
- package/src/core/index.ts +1 -0
- package/src/utils/cacheUtils.ts +70 -47
- package/src/webtriggers/clearCacheSchedulerTrigger.ts +24 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forge-sql-orm",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.26",
|
|
4
4
|
"description": "Drizzle ORM integration for Atlassian @forge/sql. Provides a custom driver, schema migration, two levels of caching (local and global via @forge/kvs), optimistic locking, and query analysis.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"homepage": "https://github.com/forge-sql-orm/forge-sql-orm#readme",
|
|
@@ -27,23 +27,23 @@
|
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@eslint/js": "^9.39.2",
|
|
29
29
|
"@types/luxon": "^3.7.1",
|
|
30
|
-
"@types/node": "^25.2
|
|
31
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
32
|
-
"@typescript-eslint/parser": "^8.
|
|
33
|
-
"@vitest/coverage-v8": "^4.
|
|
34
|
-
"@vitest/ui": "^4.
|
|
30
|
+
"@types/node": "^25.5.2",
|
|
31
|
+
"@typescript-eslint/eslint-plugin": "^8.58.1",
|
|
32
|
+
"@typescript-eslint/parser": "^8.58.1",
|
|
33
|
+
"@vitest/coverage-v8": "^4.1.3",
|
|
34
|
+
"@vitest/ui": "^4.1.3",
|
|
35
35
|
"eslint": "^9.39.2",
|
|
36
36
|
"eslint-config-prettier": "^10.1.8",
|
|
37
37
|
"eslint-plugin-import": "^2.32.0",
|
|
38
38
|
"eslint-plugin-vitest": "^0.5.4",
|
|
39
39
|
"husky": "^9.1.7",
|
|
40
|
-
"knip": "^
|
|
40
|
+
"knip": "^6.3.1",
|
|
41
41
|
"patch-package": "^8.0.1",
|
|
42
42
|
"prettier": "^3.8.1",
|
|
43
43
|
"ts-node": "^10.9.2",
|
|
44
44
|
"typescript": "^5.9.3",
|
|
45
45
|
"uuid": "^13.0.0",
|
|
46
|
-
"vitest": "^4.
|
|
46
|
+
"vitest": "^4.1.3"
|
|
47
47
|
},
|
|
48
48
|
"license": "MIT",
|
|
49
49
|
"author": "Vasyl Zakharchenko",
|
|
@@ -71,15 +71,15 @@
|
|
|
71
71
|
"README.md"
|
|
72
72
|
],
|
|
73
73
|
"peerDependencies": {
|
|
74
|
-
"@forge/sql": "^3.0.
|
|
75
|
-
"drizzle-orm": "^0.45.
|
|
74
|
+
"@forge/sql": "^3.0.21",
|
|
75
|
+
"drizzle-orm": "^0.45.2"
|
|
76
76
|
},
|
|
77
77
|
"optionalDependencies": {
|
|
78
|
-
"@forge/kvs": "^1.
|
|
78
|
+
"@forge/kvs": "^1.4.0"
|
|
79
79
|
},
|
|
80
80
|
"dependencies": {
|
|
81
|
-
"@forge/api": "^7.
|
|
82
|
-
"@forge/events": "^2.
|
|
81
|
+
"@forge/api": "^7.1.2",
|
|
82
|
+
"@forge/events": "^2.1.2",
|
|
83
83
|
"luxon": "^3.7.2",
|
|
84
84
|
"node-sql-parser": "^5.4.0"
|
|
85
85
|
},
|
|
@@ -1282,6 +1282,32 @@ export interface ForgeSqlOrmOptions {
|
|
|
1282
1282
|
/**
|
|
1283
1283
|
* Creates a custom type for date/time fields with specified format and timestamp validation.
|
|
1284
1284
|
*/
|
|
1285
|
+
function normalizeDateOnlyValue(value: unknown): unknown {
|
|
1286
|
+
if (typeof value !== "string") {
|
|
1287
|
+
return value;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const trimmedValue = value.trim();
|
|
1291
|
+
|
|
1292
|
+
const dotSeparatedMatch = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(trimmedValue);
|
|
1293
|
+
if (dotSeparatedMatch) {
|
|
1294
|
+
const [, day, month, year] = dotSeparatedMatch;
|
|
1295
|
+
return `${year}-${month}-${day} 00:00:00.000`;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
const slashSeparatedMatch = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(trimmedValue);
|
|
1299
|
+
if (slashSeparatedMatch) {
|
|
1300
|
+
const [, day, month, year] = slashSeparatedMatch;
|
|
1301
|
+
return `${year}-${month}-${day} 00:00:00.000`;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
if (trimmedValue.length === 10) {
|
|
1305
|
+
return `${trimmedValue} 00:00:00.000`;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
return value;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1285
1311
|
function createDateCustomType(dataType: string, format: string, isTimeStamp: boolean) {
|
|
1286
1312
|
return customType<{
|
|
1287
1313
|
data: Date;
|
|
@@ -1295,6 +1321,9 @@ function createDateCustomType(dataType: string, format: string, isTimeStamp: boo
|
|
|
1295
1321
|
return formatDateTime(value, format, isTimeStamp);
|
|
1296
1322
|
},
|
|
1297
1323
|
fromDriver(value: unknown) {
|
|
1324
|
+
if (value === null || value === undefined) {
|
|
1325
|
+
return value as unknown as Date;
|
|
1326
|
+
}
|
|
1298
1327
|
return parseDateTime(value as string, format);
|
|
1299
1328
|
},
|
|
1300
1329
|
});
|
|
@@ -1306,11 +1335,25 @@ function createDateCustomType(dataType: string, format: string, isTimeStamp: boo
|
|
|
1306
1335
|
*
|
|
1307
1336
|
* @type {CustomType}
|
|
1308
1337
|
*/
|
|
1309
|
-
export const forgeDateTimeString =
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1338
|
+
export const forgeDateTimeString = customType<{
|
|
1339
|
+
data: Date;
|
|
1340
|
+
driver: string;
|
|
1341
|
+
config: { format?: string };
|
|
1342
|
+
}>({
|
|
1343
|
+
dataType() {
|
|
1344
|
+
return "datetime";
|
|
1345
|
+
},
|
|
1346
|
+
toDriver(value: Date) {
|
|
1347
|
+
return formatDateTime(value, "yyyy-MM-dd' 'HH:mm:ss.SSS", false);
|
|
1348
|
+
},
|
|
1349
|
+
fromDriver(value: unknown) {
|
|
1350
|
+
if (value === null || value === undefined) {
|
|
1351
|
+
return value as unknown as Date;
|
|
1352
|
+
}
|
|
1353
|
+
const normalizedValue = normalizeDateOnlyValue(value);
|
|
1354
|
+
return parseDateTime(normalizedValue as string, "yyyy-MM-dd' 'HH:mm:ss.SSS");
|
|
1355
|
+
},
|
|
1356
|
+
});
|
|
1314
1357
|
|
|
1315
1358
|
/**
|
|
1316
1359
|
* Custom type for MySQL timestamp fields.
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { customType } from "drizzle-orm/mysql-core";
|
|
2
|
+
import { sql, type SQL, type AnyColumn } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
type VectorConfig = {
|
|
5
|
+
dimension?: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function validateVectorValue(value: number[]): void {
|
|
9
|
+
if (!Array.isArray(value)) {
|
|
10
|
+
throw new Error("TiDB vector value must be an array of numbers");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
for (const item of value) {
|
|
14
|
+
if (typeof item !== "number" || !Number.isFinite(item)) {
|
|
15
|
+
throw new Error("TiDB vector contains invalid number");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseVectorText(value: string): number[] {
|
|
21
|
+
const trimmed = value.trim();
|
|
22
|
+
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) {
|
|
23
|
+
throw new Error(`Invalid TiDB vector text: ${value}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// TiDB stores vectors as textual representation, e.g. "[0.3,0.5,-0.1]".
|
|
27
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
28
|
+
if (!Array.isArray(parsed)) {
|
|
29
|
+
throw new Error(`Invalid TiDB vector text: ${value}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const result = parsed.map((item) => {
|
|
33
|
+
if (typeof item !== "number" || !Number.isFinite(item)) {
|
|
34
|
+
throw new Error(`Invalid TiDB vector element: ${String(item)}`);
|
|
35
|
+
}
|
|
36
|
+
return item;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function vectorToText(value: number[]): string {
|
|
43
|
+
validateVectorValue(value);
|
|
44
|
+
return `[${value.join(",")}]`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Input accepted by TiDB vector SQL helpers.
|
|
49
|
+
*
|
|
50
|
+
* - `number[]`: converted to `VEC_FROM_TEXT('[...]')`
|
|
51
|
+
* - `string`: treated as raw SQL vector expression, for example:
|
|
52
|
+
* `CAST('[0.3, 0.5, -0.1]' AS VECTOR)`
|
|
53
|
+
* - `SQL` / `AnyColumn`: passed through as-is
|
|
54
|
+
*/
|
|
55
|
+
type VectorInput = string | number[] | SQL | AnyColumn;
|
|
56
|
+
|
|
57
|
+
function vectorExpr(value: VectorInput): SQL {
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
return sql.raw(`VEC_FROM_TEXT('${vectorToText(value)}')`);
|
|
60
|
+
}
|
|
61
|
+
if (typeof value === "string") {
|
|
62
|
+
return sql.raw(value);
|
|
63
|
+
}
|
|
64
|
+
return sql`${value}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* TiDB `VECTOR` column type (same call shapes as other Drizzle MySQL builders).
|
|
69
|
+
*
|
|
70
|
+
* - `vectorTiDBType('embedding', { dimension: 1536 })` — SQL column name + fixed dimension (`VECTOR(1536)`).
|
|
71
|
+
* - `vectorTiDBType({ dimension: 1536 })` — config only; column name comes from the object key in `mysqlTable`.
|
|
72
|
+
* - `vectorTiDBType('embedding')` or `vectorTiDBType()` — `VECTOR` without a fixed size in DDL.
|
|
73
|
+
*
|
|
74
|
+
* Values are `number[]` in application code; the driver maps to TiDB’s text form.
|
|
75
|
+
*/
|
|
76
|
+
export const vectorTiDBType = customType<{
|
|
77
|
+
data: number[];
|
|
78
|
+
driverData: string;
|
|
79
|
+
config: VectorConfig;
|
|
80
|
+
}>({
|
|
81
|
+
dataType(config) {
|
|
82
|
+
const dim = config?.dimension;
|
|
83
|
+
return dim ? `vector(${dim})` : "vector";
|
|
84
|
+
},
|
|
85
|
+
toDriver(value: number[]) {
|
|
86
|
+
validateVectorValue(value);
|
|
87
|
+
return `[${value.join(",")}]`;
|
|
88
|
+
},
|
|
89
|
+
fromDriver(value: unknown) {
|
|
90
|
+
if (value === null || value === undefined) {
|
|
91
|
+
return value as unknown as number[];
|
|
92
|
+
}
|
|
93
|
+
if (typeof value !== "string") {
|
|
94
|
+
throw new Error(`Invalid TiDB vector driver value type: ${typeof value}`);
|
|
95
|
+
}
|
|
96
|
+
return parseVectorText(value);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Converts a text representation of a vector into a TiDB VECTOR expression.
|
|
102
|
+
*
|
|
103
|
+
* TiDB function: `VEC_FROM_TEXT(string)`.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* vecFromText("[1, 2, 3]")
|
|
107
|
+
*/
|
|
108
|
+
export function vecFromText(text: string): SQL {
|
|
109
|
+
return sql`VEC_FROM_TEXT('${text}')`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Converts a vector value/expression to its normalized string representation.
|
|
114
|
+
*
|
|
115
|
+
* TiDB function: `VEC_AS_TEXT(vector)`.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* vecAsText(table.embedding)
|
|
119
|
+
*/
|
|
120
|
+
export function vecAsText(vector: VectorInput): SQL<string> {
|
|
121
|
+
return sql<string>`VEC_AS_TEXT(${vectorExpr(vector)})`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Returns the dimension (number of elements) of a vector.
|
|
126
|
+
*
|
|
127
|
+
* TiDB function: `VEC_DIMS(vector)`.
|
|
128
|
+
*
|
|
129
|
+
* Accepts vector column/expression, `sql.raw(...)`, raw SQL string expression,
|
|
130
|
+
* or `number[]`.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* vecDims(table.embedding)
|
|
134
|
+
* vecDims(sql.raw("CAST('[0.3, 0.5, -0.1]' AS VECTOR)"))
|
|
135
|
+
* vecDims("CAST('[0.3, 0.5, -0.1]' AS VECTOR)")
|
|
136
|
+
*/
|
|
137
|
+
export function vecDims(vector: VectorInput): SQL<number> {
|
|
138
|
+
return sql<number>`VEC_DIMS(${vectorExpr(vector)})`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Calculates L2 norm (Euclidean norm) of a vector.
|
|
143
|
+
*
|
|
144
|
+
* TiDB function: `VEC_L2_NORM(vector)`.
|
|
145
|
+
*/
|
|
146
|
+
export function vecL2Norm(vector: VectorInput): SQL<number> {
|
|
147
|
+
return sql`VEC_L2_NORM(${vectorExpr(vector)})`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Calculates L2 distance (Euclidean distance) between two vectors.
|
|
152
|
+
*
|
|
153
|
+
* TiDB function: `VEC_L2_DISTANCE(vector1, vector2)`.
|
|
154
|
+
*
|
|
155
|
+
* Both vectors must have the same dimensions.
|
|
156
|
+
*/
|
|
157
|
+
export function vecL2Distance(left: VectorInput, right: VectorInput): SQL<number> {
|
|
158
|
+
return sql<number>`VEC_L2_DISTANCE(${vectorExpr(left)}, ${vectorExpr(right)})`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Calculates cosine distance between two vectors.
|
|
163
|
+
*
|
|
164
|
+
* TiDB function: `VEC_COSINE_DISTANCE(vector1, vector2)`.
|
|
165
|
+
*
|
|
166
|
+
* Both vectors must have the same dimensions.
|
|
167
|
+
*/
|
|
168
|
+
export function vecCosineDistance(left: VectorInput, right: VectorInput): SQL<number> {
|
|
169
|
+
return sql<number>`VEC_COSINE_DISTANCE(${vectorExpr(left)}, ${vectorExpr(right)})`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Calculates negative inner product distance between two vectors.
|
|
174
|
+
*
|
|
175
|
+
* TiDB function: `VEC_NEGATIVE_INNER_PRODUCT(vector1, vector2)`.
|
|
176
|
+
*
|
|
177
|
+
* Both vectors must have the same dimensions.
|
|
178
|
+
*/
|
|
179
|
+
export function vecNegativeInnerProduct(left: VectorInput, right: VectorInput): SQL<number> {
|
|
180
|
+
return sql<number>`VEC_NEGATIVE_INNER_PRODUCT(${vectorExpr(left)}, ${vectorExpr(right)})`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Calculates L1 distance (Manhattan distance) between two vectors.
|
|
185
|
+
*
|
|
186
|
+
* TiDB function: `VEC_L1_DISTANCE(vector1, vector2)`.
|
|
187
|
+
*
|
|
188
|
+
* Both vectors must have the same dimensions.
|
|
189
|
+
*/
|
|
190
|
+
export function vecL1Distance(left: VectorInput, right: VectorInput): SQL<number> {
|
|
191
|
+
return sql<number>`VEC_L1_DISTANCE(${vectorExpr(left)}, ${vectorExpr(right)})`;
|
|
192
|
+
}
|
package/src/core/index.ts
CHANGED
package/src/utils/cacheUtils.ts
CHANGED
|
@@ -48,6 +48,30 @@ function nowPlusSeconds(secondsToAdd: number): number {
|
|
|
48
48
|
return Math.floor(dt.toSeconds());
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Logs a message to console.debug when options.logCache is enabled.
|
|
53
|
+
*
|
|
54
|
+
* @param message - Message to log
|
|
55
|
+
* @param options - ForgeSQL ORM options (optional)
|
|
56
|
+
*/
|
|
57
|
+
function debugLog(message: string, options?: ForgeSqlOrmOptions): void {
|
|
58
|
+
if (options?.logCache) {
|
|
59
|
+
// eslint-disable-next-line no-console
|
|
60
|
+
console.debug(message);
|
|
61
|
+
}
|
|
62
|
+
} /**
|
|
63
|
+
* Logs a message to console.debug when options.logCache is enabled.
|
|
64
|
+
*
|
|
65
|
+
* @param message - Message to log
|
|
66
|
+
* @param options - ForgeSQL ORM options (optional)
|
|
67
|
+
*/
|
|
68
|
+
function warnLog(message: string, options?: ForgeSqlOrmOptions): void {
|
|
69
|
+
if (options?.logCache) {
|
|
70
|
+
// eslint-disable-next-line no-console
|
|
71
|
+
console.warn(message);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
51
75
|
/**
|
|
52
76
|
* Generates a hash key for a query based on its SQL and parameters.
|
|
53
77
|
*
|
|
@@ -66,19 +90,20 @@ export function hashKey(query: Query): string {
|
|
|
66
90
|
*
|
|
67
91
|
* @param results - Array of cache entries to delete
|
|
68
92
|
* @param cacheEntityName - Name of the cache entity
|
|
93
|
+
* @param options - Forge SQL ORM properties
|
|
69
94
|
* @returns Promise that resolves when all deletions are complete
|
|
70
95
|
*/
|
|
71
96
|
async function deleteCacheEntriesInBatches(
|
|
72
97
|
results: Array<{ key: string }>,
|
|
73
98
|
cacheEntityName: string,
|
|
99
|
+
options?: ForgeSqlOrmOptions,
|
|
74
100
|
): Promise<void> {
|
|
75
101
|
for (let i = 0; i < results.length; i += CACHE_CONSTANTS.BATCH_SIZE) {
|
|
76
102
|
const batch = results.slice(i, i + CACHE_CONSTANTS.BATCH_SIZE);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
await transactionBuilder.execute();
|
|
103
|
+
const batchResult = await kvs.batchDelete(
|
|
104
|
+
batch.map((result) => ({ key: result.key, entityName: cacheEntityName })),
|
|
105
|
+
);
|
|
106
|
+
batchResult.failedKeys.forEach((failedKey) => warnLog(JSON.stringify(failedKey), options));
|
|
82
107
|
}
|
|
83
108
|
}
|
|
84
109
|
|
|
@@ -124,12 +149,9 @@ async function clearCursorCache(
|
|
|
124
149
|
|
|
125
150
|
const listResult = await entityQueryBuilder.limit(100).getMany();
|
|
126
151
|
|
|
127
|
-
|
|
128
|
-
// eslint-disable-next-line no-console
|
|
129
|
-
console.warn(`clear cache Records: ${JSON.stringify(listResult.results.map((r) => r.key))}`);
|
|
130
|
-
}
|
|
152
|
+
debugLog(`clear cache Records: ${JSON.stringify(listResult.results.map((r) => r.key))}`, options);
|
|
131
153
|
|
|
132
|
-
await deleteCacheEntriesInBatches(listResult.results, cacheEntityName);
|
|
154
|
+
await deleteCacheEntriesInBatches(listResult.results, cacheEntityName, options);
|
|
133
155
|
|
|
134
156
|
if (listResult.nextCursor) {
|
|
135
157
|
return (
|
|
@@ -172,10 +194,10 @@ async function clearExpirationCursorCache(
|
|
|
172
194
|
|
|
173
195
|
const listResult = await entityQueryBuilder.limit(100).getMany();
|
|
174
196
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
197
|
+
debugLog(
|
|
198
|
+
`clear expired Records: ${JSON.stringify(listResult.results.map((r) => r.key))}`,
|
|
199
|
+
options,
|
|
200
|
+
);
|
|
179
201
|
|
|
180
202
|
await deleteCacheEntriesInBatches(listResult.results, cacheEntityName);
|
|
181
203
|
|
|
@@ -265,14 +287,12 @@ export async function clearTablesCache(
|
|
|
265
287
|
"clearing cache",
|
|
266
288
|
);
|
|
267
289
|
} finally {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
// eslint-disable-next-line no-console
|
|
271
|
-
console.info(`Cleared ${totalRecords} cache records in ${duration} seconds`);
|
|
272
|
-
}
|
|
290
|
+
const duration = DateTime.now().toSeconds() - startTime.toSeconds();
|
|
291
|
+
debugLog(`Cleared ${totalRecords} cache records in ${duration} seconds`, options);
|
|
273
292
|
}
|
|
274
293
|
}
|
|
275
294
|
/**
|
|
295
|
+
* since https://developer.atlassian.com/platform/forge/changelog/#CHANGE-3038
|
|
276
296
|
* Clears expired cache entries with retry logic and performance logging.
|
|
277
297
|
*
|
|
278
298
|
* @param options - ForgeSQL ORM options
|
|
@@ -293,16 +313,17 @@ export async function clearExpiredCache(options: ForgeSqlOrmOptions): Promise<vo
|
|
|
293
313
|
);
|
|
294
314
|
} finally {
|
|
295
315
|
const duration = DateTime.now().toSeconds() - startTime.toSeconds();
|
|
296
|
-
|
|
297
|
-
// eslint-disable-next-line no-console
|
|
298
|
-
console.debug(`Cleared ${totalRecords} expired cache records in ${duration} seconds`);
|
|
299
|
-
}
|
|
316
|
+
debugLog(`Cleared ${totalRecords} expired cache records in ${duration} seconds`, options);
|
|
300
317
|
}
|
|
301
318
|
}
|
|
302
319
|
|
|
303
320
|
/**
|
|
304
321
|
* Retrieves data from cache if it exists and is not expired.
|
|
305
322
|
*
|
|
323
|
+
* Note: Due to Forge KVS asynchronous deletion (up to 48 hours), expired entries may still
|
|
324
|
+
* be returned. This function checks the expiration timestamp to filter out expired entries.
|
|
325
|
+
* If cache growth impacts performance, use the Clear Cache Scheduler Trigger.
|
|
326
|
+
*
|
|
306
327
|
* @param query - Query object with toSQL method
|
|
307
328
|
* @param options - ForgeSQL ORM options
|
|
308
329
|
* @returns Cached data if found and valid, undefined otherwise
|
|
@@ -325,27 +346,27 @@ export async function getFromCache<T>(
|
|
|
325
346
|
|
|
326
347
|
// Skip cache if table is in cache context (will be cleared)
|
|
327
348
|
if (await isTableContainsTableInCacheContext(sqlQuery.sql, options)) {
|
|
328
|
-
|
|
329
|
-
// eslint-disable-next-line no-console
|
|
330
|
-
console.warn(`Context contains value to clear. Skip getting from cache`);
|
|
331
|
-
}
|
|
349
|
+
debugLog("Context contains value to clear. Skip getting from cache", options);
|
|
332
350
|
return undefined;
|
|
333
351
|
}
|
|
334
352
|
|
|
335
353
|
try {
|
|
336
354
|
const cacheResult = await kvs.entity<CacheEntity>(options.cacheEntityName).get(key);
|
|
337
355
|
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
356
|
+
if (cacheResult) {
|
|
357
|
+
if (
|
|
358
|
+
(cacheResult[expirationName] as number) >= getCurrentTime() &&
|
|
359
|
+
extractBacktickedValues(sqlQuery.sql, options) === cacheResult[entityQueryName]
|
|
360
|
+
) {
|
|
361
|
+
debugLog(`Get value from cache, cacheKey: ${key}`, options);
|
|
362
|
+
const results = cacheResult[dataName];
|
|
363
|
+
return JSON.parse(results as string);
|
|
364
|
+
} else {
|
|
365
|
+
debugLog(
|
|
366
|
+
`Expired cache entry still exists (will be automatically removed within 48 hours per Forge KVS TTL documentation), cacheKey: ${key}`,
|
|
367
|
+
options,
|
|
368
|
+
);
|
|
346
369
|
}
|
|
347
|
-
const results = cacheResult[dataName];
|
|
348
|
-
return JSON.parse(results as string);
|
|
349
370
|
}
|
|
350
371
|
} catch (error: any) {
|
|
351
372
|
// eslint-disable-next-line no-console
|
|
@@ -358,11 +379,18 @@ export async function getFromCache<T>(
|
|
|
358
379
|
/**
|
|
359
380
|
* Stores query results in cache with specified TTL.
|
|
360
381
|
*
|
|
382
|
+
* Uses Forge KVS TTL feature to set expiration. Note that expired data deletion is asynchronous:
|
|
383
|
+
* expired data is not removed immediately upon expiry. Deletion may take up to 48 hours.
|
|
384
|
+
* During this window, read operations may still return expired results. If your app requires
|
|
385
|
+
* strict expiry semantics, consider using the Clear Cache Scheduler Trigger to proactively
|
|
386
|
+
* clean up expired entries, especially if cache growth impacts INSERT/UPDATE performance.
|
|
387
|
+
*
|
|
361
388
|
* @param query - Query object with toSQL method
|
|
362
389
|
* @param options - ForgeSQL ORM options
|
|
363
390
|
* @param results - Data to cache
|
|
364
|
-
* @param cacheTtl - Time to live in seconds
|
|
391
|
+
* @param cacheTtl - Time to live in seconds (maximum TTL is 1 year from write time)
|
|
365
392
|
* @returns Promise that resolves when data is stored in cache
|
|
393
|
+
* @see https://developer.atlassian.com/platform/forge/runtime-reference/storage-api-basic-api/#ttl
|
|
366
394
|
*/
|
|
367
395
|
export async function setCacheResult(
|
|
368
396
|
query: { toSQL: () => Query },
|
|
@@ -385,10 +413,7 @@ export async function setCacheResult(
|
|
|
385
413
|
|
|
386
414
|
// Skip cache if table is in cache context (will be cleared)
|
|
387
415
|
if (await isTableContainsTableInCacheContext(sqlQuery.sql, options)) {
|
|
388
|
-
|
|
389
|
-
// eslint-disable-next-line no-console
|
|
390
|
-
console.warn(`Context contains value to clear. Skip setting from cache`);
|
|
391
|
-
}
|
|
416
|
+
debugLog("Context contains value to clear. Skip setting from cache", options);
|
|
392
417
|
return;
|
|
393
418
|
}
|
|
394
419
|
|
|
@@ -400,17 +425,15 @@ export async function setCacheResult(
|
|
|
400
425
|
key,
|
|
401
426
|
{
|
|
402
427
|
[entityQueryName]: extractBacktickedValues(sqlQuery.sql, options),
|
|
403
|
-
[expirationName]: nowPlusSeconds(cacheTtl),
|
|
428
|
+
[expirationName]: nowPlusSeconds(cacheTtl + 2),
|
|
404
429
|
[dataName]: JSON.stringify(results),
|
|
405
430
|
},
|
|
406
431
|
{ entityName: options.cacheEntityName },
|
|
432
|
+
{ ttl: { value: cacheTtl, unit: "SECONDS" } },
|
|
407
433
|
)
|
|
408
434
|
.execute();
|
|
409
435
|
|
|
410
|
-
|
|
411
|
-
// eslint-disable-next-line no-console
|
|
412
|
-
console.warn(`Store value to cache, cacheKey: ${key}`);
|
|
413
|
-
}
|
|
436
|
+
debugLog(`Store value to cache, cacheKey: ${key}`, options);
|
|
414
437
|
} catch (error: any) {
|
|
415
438
|
// eslint-disable-next-line no-console
|
|
416
439
|
console.error(`Error setting cache: ${error.message}`, error);
|
|
@@ -2,10 +2,28 @@ import { clearExpiredCache } from "../utils/cacheUtils";
|
|
|
2
2
|
import { ForgeSqlOrmOptions } from "../core/ForgeSQLQueryBuilder";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Scheduler trigger for clearing expired cache entries.
|
|
5
|
+
* Scheduler trigger for proactively clearing expired cache entries.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* **Why this trigger is needed:**
|
|
8
|
+
*
|
|
9
|
+
* While forge-sql-orm uses Forge KVS TTL feature to mark entries as expired, **actual deletion
|
|
10
|
+
* is asynchronous and may take up to 48 hours**. During this window, expired entries remain in
|
|
11
|
+
* storage and can impact INSERT/UPDATE performance if the cache grows large.
|
|
12
|
+
*
|
|
13
|
+
* This scheduler trigger proactively cleans up expired entries by querying the expiration index
|
|
14
|
+
* and deleting entries where expiration < now, preventing cache growth from impacting data
|
|
15
|
+
* modification operations.
|
|
16
|
+
*
|
|
17
|
+
* **When to use:**
|
|
18
|
+
* - Your cache grows large over time
|
|
19
|
+
* - INSERT/UPDATE operations are slowing down due to cache size
|
|
20
|
+
* - You need strict expiry semantics (immediate cleanup)
|
|
21
|
+
* - You want to reduce storage costs proactively
|
|
22
|
+
*
|
|
23
|
+
* **When optional:**
|
|
24
|
+
* - Small cache footprint
|
|
25
|
+
* - No performance impact on data modifications
|
|
26
|
+
* - You can tolerate expired entries being returned for up to 48 hours
|
|
9
27
|
*
|
|
10
28
|
* @note This function is automatically disabled in production environments and will return a 500 error if called.
|
|
11
29
|
*
|
|
@@ -28,7 +46,7 @@ import { ForgeSqlOrmOptions } from "../core/ForgeSQLQueryBuilder";
|
|
|
28
46
|
*
|
|
29
47
|
* @example
|
|
30
48
|
* ```yaml
|
|
31
|
-
* # In manifest.yml
|
|
49
|
+
* # In manifest.yml (optional - only if cache growth impacts INSERT/UPDATE performance)
|
|
32
50
|
* scheduledTrigger:
|
|
33
51
|
* - key: clear-cache-trigger
|
|
34
52
|
* function: clearCache
|
|
@@ -38,6 +56,8 @@ import { ForgeSqlOrmOptions } from "../core/ForgeSQLQueryBuilder";
|
|
|
38
56
|
* - key: clearCache
|
|
39
57
|
* handler: index.clearCache
|
|
40
58
|
* ```
|
|
59
|
+
*
|
|
60
|
+
* @see https://developer.atlassian.com/platform/forge/runtime-reference/storage-api-basic-api/#ttl
|
|
41
61
|
*/
|
|
42
62
|
export const clearCacheSchedulerTrigger = async (options?: ForgeSqlOrmOptions) => {
|
|
43
63
|
try {
|