@comake/skl-js-engine 1.4.2 → 1.5.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/dist/SklEngine.d.ts +9 -0
- package/dist/SklEngine.d.ts.map +1 -1
- package/dist/SklEngine.js +122 -12
- package/dist/SklEngine.js.map +1 -1
- package/dist/SklEngineOptions.d.ts +32 -0
- package/dist/SklEngineOptions.d.ts.map +1 -1
- package/dist/SklEngineOptions.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/storage/FindOptionsTypes.d.ts +4 -1
- package/dist/storage/FindOptionsTypes.d.ts.map +1 -1
- package/dist/storage/FindOptionsTypes.js.map +1 -1
- package/dist/storage/query-adapter/sparql/SparqlQueryBuilder.d.ts.map +1 -1
- package/dist/storage/query-adapter/sparql/SparqlQueryBuilder.js +21 -0
- package/dist/storage/query-adapter/sparql/SparqlQueryBuilder.js.map +1 -1
- package/dist/tools/explain-findall-sparql.d.ts +2 -0
- package/dist/tools/explain-findall-sparql.d.ts.map +1 -0
- package/dist/tools/explain-findall-sparql.js +303 -0
- package/dist/tools/explain-findall-sparql.js.map +1 -0
- package/dist/util/ReadCacheHelper.d.ts +14 -0
- package/dist/util/ReadCacheHelper.d.ts.map +1 -0
- package/dist/util/ReadCacheHelper.js +61 -0
- package/dist/util/ReadCacheHelper.js.map +1 -0
- package/package.json +2 -1
- package/src/SklEngine.ts +150 -13
- package/src/SklEngineOptions.ts +40 -0
- package/src/index.ts +1 -0
- package/src/storage/FindOptionsTypes.ts +4 -1
- package/src/storage/query-adapter/sparql/SparqlQueryBuilder.ts +22 -0
- package/src/tools/explain-findall-sparql.ts +387 -0
- package/src/util/ReadCacheHelper.ts +64 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
/* eslint-comments/no-unlimited-disable */
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
import DataFactory from '@rdfjs/data-model';
|
|
7
|
+
import type { NamedNode, Literal } from '@rdfjs/types';
|
|
8
|
+
import type { AggregateExpression, ConstructQuery, Pattern, SelectQuery, Variable } from 'sparqljs';
|
|
9
|
+
import { Generator } from 'sparqljs';
|
|
10
|
+
|
|
11
|
+
import type { FindAllOptions, FindOptionsWhere } from '../storage/FindOptionsTypes';
|
|
12
|
+
import { SparqlQueryBuilder } from '../storage/query-adapter/sparql/SparqlQueryBuilder';
|
|
13
|
+
import {
|
|
14
|
+
createSparqlBasicGraphPattern,
|
|
15
|
+
createSparqlGraphPattern,
|
|
16
|
+
createSparqlSelectQuery,
|
|
17
|
+
createValuesPatternsForVariables,
|
|
18
|
+
entityGraphTriple,
|
|
19
|
+
entityVariable,
|
|
20
|
+
rdfTypeNamedNode,
|
|
21
|
+
rdfTypesVariable,
|
|
22
|
+
rdfTypeVariable,
|
|
23
|
+
createSparqlSelectGroup
|
|
24
|
+
} from '../util/SparqlUtil';
|
|
25
|
+
import { ensureArray } from '../util/Util';
|
|
26
|
+
|
|
27
|
+
type OutputFormat = 'text' | 'json';
|
|
28
|
+
|
|
29
|
+
type PlanStep =
|
|
30
|
+
| {
|
|
31
|
+
kind: 'select';
|
|
32
|
+
name: string;
|
|
33
|
+
wouldExecute: boolean;
|
|
34
|
+
sparql: string;
|
|
35
|
+
notes?: string[];
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
kind: 'construct';
|
|
39
|
+
name: string;
|
|
40
|
+
wouldExecute: boolean;
|
|
41
|
+
sparql: string;
|
|
42
|
+
notes?: string[];
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
kind: 'note';
|
|
46
|
+
name: string;
|
|
47
|
+
wouldExecute: boolean;
|
|
48
|
+
notes: string[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
interface ExplainPlan {
|
|
52
|
+
input: unknown;
|
|
53
|
+
normalizedOptions: FindAllOptions;
|
|
54
|
+
steps: PlanStep[];
|
|
55
|
+
meta: Record<string, any>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function usageAndExit(exitCode: number): never {
|
|
59
|
+
const msg = [
|
|
60
|
+
'Usage:',
|
|
61
|
+
' node dist/tools/explain-findall-sparql.js --input <file.json> [--format text|json] [--simulate-entity-values N]',
|
|
62
|
+
'',
|
|
63
|
+
'Input JSON can be either:',
|
|
64
|
+
' 1) a full FindAllOptions object: { "where": { ... }, "relations": { ... }, "order": { ... }, "limit": 10, ... }',
|
|
65
|
+
' 2) a FindOptionsWhere object (treated as { where: <object> })',
|
|
66
|
+
'',
|
|
67
|
+
'Notes:',
|
|
68
|
+
' - This utility does not query a SPARQL endpoint. When findAll would inject VALUES(?entity) from a pre-SELECT,',
|
|
69
|
+
' you can pass --simulate-entity-values N to show an example VALUES block with N placeholder IRIs.',
|
|
70
|
+
''
|
|
71
|
+
].join('\n');
|
|
72
|
+
|
|
73
|
+
console.error(msg);
|
|
74
|
+
|
|
75
|
+
process.exit(exitCode);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseArgs(argv: string[]): { input?: string; format: OutputFormat; simulateEntityValues: number } {
|
|
79
|
+
const out: { input?: string; format: OutputFormat; simulateEntityValues: number } = {
|
|
80
|
+
format: 'text',
|
|
81
|
+
simulateEntityValues: 0
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
85
|
+
const arg = argv[i];
|
|
86
|
+
if (arg === '--help' || arg === '-h') {
|
|
87
|
+
usageAndExit(0);
|
|
88
|
+
}
|
|
89
|
+
if (arg === '--input' || arg === '-i') {
|
|
90
|
+
out.input = argv[i + 1];
|
|
91
|
+
i += 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (arg === '--format' || arg === '-f') {
|
|
95
|
+
const v = argv[i + 1] as OutputFormat | undefined;
|
|
96
|
+
if (v !== 'text' && v !== 'json') {
|
|
97
|
+
console.error(`Invalid --format: ${String(v)}`);
|
|
98
|
+
usageAndExit(2);
|
|
99
|
+
}
|
|
100
|
+
out.format = v;
|
|
101
|
+
i += 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (arg === '--simulate-entity-values') {
|
|
105
|
+
const v = Number.parseInt(argv[i + 1] ?? '', 10);
|
|
106
|
+
if (!Number.isFinite(v) || v < 0) {
|
|
107
|
+
console.error(`Invalid --simulate-entity-values: ${argv[i + 1]}`);
|
|
108
|
+
usageAndExit(2);
|
|
109
|
+
}
|
|
110
|
+
out.simulateEntityValues = v;
|
|
111
|
+
i += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (arg.startsWith('-')) {
|
|
115
|
+
console.error(`Unknown arg: ${arg}`);
|
|
116
|
+
usageAndExit(2);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readJsonFile(p: string): any {
|
|
124
|
+
const abs = path.isAbsolute(p) ? p : path.join(process.cwd(), p);
|
|
125
|
+
const raw = fs.readFileSync(abs, 'utf8');
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(raw);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
throw new Error(`Failed to parse JSON from ${abs}: ${(e as Error).message}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function coerceSparqlVariable(v: any): Variable | undefined {
|
|
134
|
+
if (!v) return undefined;
|
|
135
|
+
if (typeof v === 'string') {
|
|
136
|
+
const name = v.startsWith('?') ? v.slice(1) : v;
|
|
137
|
+
return DataFactory.variable(name) as any;
|
|
138
|
+
}
|
|
139
|
+
if (typeof v === 'object' && v.termType === 'Variable' && typeof v.value === 'string') {
|
|
140
|
+
return v;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeFindAllOptions(input: any): FindAllOptions {
|
|
146
|
+
const asOptions =
|
|
147
|
+
typeof input === 'object' &&
|
|
148
|
+
input !== null &&
|
|
149
|
+
('where' in input || 'select' in input || 'relations' in input || 'order' in input || 'limit' in input || 'offset' in input || 'subQueries' in input);
|
|
150
|
+
|
|
151
|
+
const options: FindAllOptions = asOptions ? input : { where: input as FindOptionsWhere };
|
|
152
|
+
|
|
153
|
+
// Allow simple JSON-friendly forms for variables.
|
|
154
|
+
const group = coerceSparqlVariable((options as any).group);
|
|
155
|
+
const entitySelectVariable = coerceSparqlVariable((options as any).entitySelectVariable);
|
|
156
|
+
|
|
157
|
+
const subQueries = Array.isArray((options as any).subQueries)
|
|
158
|
+
? (options as any).subQueries.map((sq: any) => {
|
|
159
|
+
const select = Array.isArray(sq?.select) ? sq.select.map(coerceSparqlVariable).filter(Boolean) : sq?.select;
|
|
160
|
+
return { ...sq, ...(select ? { select } : {}) };
|
|
161
|
+
})
|
|
162
|
+
: (options as any).subQueries;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
...options,
|
|
166
|
+
...(group ? { group } : {}),
|
|
167
|
+
...(entitySelectVariable ? { entitySelectVariable } : {}),
|
|
168
|
+
...(subQueries ? { subQueries } : {})
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function stringifyQuery(query: SelectQuery | ConstructQuery): string {
|
|
173
|
+
const gen = new Generator();
|
|
174
|
+
return gen.stringify(query);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildEntitySelectQueryForFindAll(
|
|
178
|
+
selectQueryData: ReturnType<SparqlQueryBuilder['buildEntitySelectPatternsFromOptions']>,
|
|
179
|
+
options?: FindAllOptions
|
|
180
|
+
): SelectQuery | undefined {
|
|
181
|
+
// Mirrors SparqlQueryAdapter.buildFindAllQueryData() for the entitySelectQuery creation.
|
|
182
|
+
const wherePatterns: Pattern[] = [...selectQueryData.where, ...selectQueryData.graphWhere];
|
|
183
|
+
wherePatterns.push({
|
|
184
|
+
type: 'bgp',
|
|
185
|
+
triples: [
|
|
186
|
+
{
|
|
187
|
+
subject: entityVariable,
|
|
188
|
+
predicate: rdfTypeNamedNode,
|
|
189
|
+
object: rdfTypeVariable
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const entitySelectVariable = options?.entitySelectVariable ?? entityVariable;
|
|
195
|
+
const groupBy = ensureArray(selectQueryData?.group ?? options?.group ?? []);
|
|
196
|
+
groupBy.push(entitySelectVariable);
|
|
197
|
+
|
|
198
|
+
// All non-aggregated variables in SELECT must be in GROUP BY
|
|
199
|
+
for (const selectVariable of selectQueryData.selectVariables ?? []) {
|
|
200
|
+
const expr = selectVariable.expression as any;
|
|
201
|
+
if (!('aggregation' in (expr as AggregateExpression)) && expr?.constructor?.name === 'Variable') {
|
|
202
|
+
groupBy.push(expr as Variable);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (selectQueryData.where.length === 0) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return createSparqlSelectQuery(
|
|
211
|
+
[
|
|
212
|
+
entitySelectVariable,
|
|
213
|
+
// (GROUP_CONCAT(DISTINCT str(?rdfType); SEPARATOR = " | ") AS ?rdfTypes)
|
|
214
|
+
{
|
|
215
|
+
expression: {
|
|
216
|
+
type: 'aggregate',
|
|
217
|
+
aggregation: 'group_concat',
|
|
218
|
+
separator: ' | ',
|
|
219
|
+
distinct: true,
|
|
220
|
+
expression: {
|
|
221
|
+
type: 'operation',
|
|
222
|
+
operator: 'STR',
|
|
223
|
+
args: [rdfTypeVariable]
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
variable: rdfTypesVariable
|
|
227
|
+
} as any,
|
|
228
|
+
...(selectQueryData.selectVariables?.map(({ variable, expression }) => {
|
|
229
|
+
if (!expression) return variable as any;
|
|
230
|
+
return { variable, expression } as any;
|
|
231
|
+
}) ?? [])
|
|
232
|
+
] as any,
|
|
233
|
+
wherePatterns,
|
|
234
|
+
selectQueryData.orders,
|
|
235
|
+
groupBy as any,
|
|
236
|
+
options?.limit,
|
|
237
|
+
options?.offset
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function simulateEntityIdValues(n: number): (NamedNode | Literal)[] {
|
|
242
|
+
const values: NamedNode[] = [];
|
|
243
|
+
for (let i = 1; i <= n; i += 1) {
|
|
244
|
+
values.push(DataFactory.namedNode(`urn:skl-dry-run:entity-${i}`));
|
|
245
|
+
}
|
|
246
|
+
return values;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function main(): Promise<void> {
|
|
250
|
+
const args = parseArgs(process.argv.slice(2));
|
|
251
|
+
if (!args.input) {
|
|
252
|
+
usageAndExit(2);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const input = readJsonFile(args.input);
|
|
256
|
+
const options = normalizeFindAllOptions(input);
|
|
257
|
+
|
|
258
|
+
const qb = new SparqlQueryBuilder();
|
|
259
|
+
const queryData = qb.buildEntitySelectPatternsFromOptions(entityVariable, options);
|
|
260
|
+
const selectQueryData = qb.buildEntitySelectPatternsFromOptions(entityVariable, {
|
|
261
|
+
...options,
|
|
262
|
+
relations: undefined
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Mirrors SparqlQueryAdapter.buildFindAllQueryData() for the relations union tweak.
|
|
266
|
+
if ((queryData?.relationsQueryData?.unionPatterns ?? []).length > 0) {
|
|
267
|
+
queryData?.relationsQueryData?.unionPatterns.push(createSparqlGraphPattern(entityVariable, [createSparqlBasicGraphPattern([entityGraphTriple])]));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const entitySelectQuery = buildEntitySelectQueryForFindAll(selectQueryData, options);
|
|
271
|
+
const wouldPreSelectForOrderingAndValues = queryData.orders.length > 0 && options?.limit !== 1 && !!entitySelectQuery;
|
|
272
|
+
|
|
273
|
+
const steps: PlanStep[] = [];
|
|
274
|
+
|
|
275
|
+
if (entitySelectQuery) {
|
|
276
|
+
const notes: string[] = [];
|
|
277
|
+
if (wouldPreSelectForOrderingAndValues) {
|
|
278
|
+
notes.push(
|
|
279
|
+
'In SparqlQueryAdapter.findAll(), this SELECT is executed first to compute entity ordering.',
|
|
280
|
+
'Its results are then used to inject a VALUES block over ?entity into the main CONSTRUCT query.'
|
|
281
|
+
);
|
|
282
|
+
if (options?.limit === undefined) {
|
|
283
|
+
notes.push('Warning: limit is undefined, so this pre-SELECT may return all matching entity IDs (potentially huge).');
|
|
284
|
+
} else {
|
|
285
|
+
notes.push(`VALUES size is <= limit (${options.limit}).`);
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
notes.push('In some cases (relations/type constraints), findAll executes this SELECT to support framing/type handling.');
|
|
289
|
+
notes.push('In that path, it also embeds this SELECT as a subquery inside the CONSTRUCT.');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
steps.push({
|
|
293
|
+
kind: 'select',
|
|
294
|
+
name: 'Entity Pre-SELECT',
|
|
295
|
+
wouldExecute: wouldPreSelectForOrderingAndValues,
|
|
296
|
+
sparql: stringifyQuery(entitySelectQuery),
|
|
297
|
+
notes
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let constructWhere = queryData.graphWhere;
|
|
302
|
+
let constructNotes: string[] = [];
|
|
303
|
+
|
|
304
|
+
if (wouldPreSelectForOrderingAndValues) {
|
|
305
|
+
if (args.simulateEntityValues > 0) {
|
|
306
|
+
const variableValueFilters = createValuesPatternsForVariables({
|
|
307
|
+
[entityVariable.value]: simulateEntityIdValues(args.simulateEntityValues) as any
|
|
308
|
+
});
|
|
309
|
+
constructWhere = [...variableValueFilters, ...constructWhere];
|
|
310
|
+
constructNotes = [
|
|
311
|
+
`This CONSTRUCT includes a simulated VALUES(?${entityVariable.value}) with ${args.simulateEntityValues} placeholder IRIs.`,
|
|
312
|
+
'In real execution, those VALUES come from the pre-SELECT results.'
|
|
313
|
+
];
|
|
314
|
+
} else {
|
|
315
|
+
constructNotes = [
|
|
316
|
+
'In real execution, this CONSTRUCT is preceded by the pre-SELECT and will have a VALUES(?entity) block injected.',
|
|
317
|
+
'Pass --simulate-entity-values N to show an example VALUES block.'
|
|
318
|
+
];
|
|
319
|
+
}
|
|
320
|
+
} else if (entitySelectQuery) {
|
|
321
|
+
// Mirrors the else-if path where the entity select is embedded into the CONSTRUCT.
|
|
322
|
+
const entitySelectGroupQuery = createSparqlSelectGroup([entitySelectQuery]);
|
|
323
|
+
constructWhere = [entitySelectGroupQuery, ...constructWhere];
|
|
324
|
+
constructNotes = ['This CONSTRUCT embeds the entity SELECT as a subquery (GROUP pattern) in its WHERE.'];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const constructQuery = qb.buildConstructFromEntitySelectQuery(constructWhere, queryData.graphSelectionTriples, options?.select, queryData.selectVariables);
|
|
328
|
+
|
|
329
|
+
steps.push({
|
|
330
|
+
kind: 'construct',
|
|
331
|
+
name: 'Main CONSTRUCT',
|
|
332
|
+
wouldExecute: true,
|
|
333
|
+
sparql: stringifyQuery(constructQuery),
|
|
334
|
+
...(constructNotes.length > 0 ? { notes: constructNotes } : {})
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
if (wouldPreSelectForOrderingAndValues && options?.limit === undefined) {
|
|
338
|
+
steps.push({
|
|
339
|
+
kind: 'note',
|
|
340
|
+
name: 'Performance Hint',
|
|
341
|
+
wouldExecute: false,
|
|
342
|
+
notes: [
|
|
343
|
+
'findAll() with no limit triggers a pre-SELECT that can return a very large ID set, then injects it into VALUES(?entity).',
|
|
344
|
+
'This often causes slow queries due to large intermediate results and huge VALUES blocks.'
|
|
345
|
+
]
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const plan: ExplainPlan = {
|
|
350
|
+
input,
|
|
351
|
+
normalizedOptions: options,
|
|
352
|
+
steps,
|
|
353
|
+
meta: {
|
|
354
|
+
wouldPreSelectForOrderingAndValues,
|
|
355
|
+
hasRelations: (queryData?.relationsQueryData?.unionPatterns ?? []).length > 0,
|
|
356
|
+
hasTypeConstraint: options?.where?.type !== undefined,
|
|
357
|
+
limit: options?.limit,
|
|
358
|
+
offset: options?.offset
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
if (args.format === 'json') {
|
|
363
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Text
|
|
368
|
+
for (const step of plan.steps) {
|
|
369
|
+
if (step.kind === 'note') {
|
|
370
|
+
console.log(`\n# ${step.name}\n`);
|
|
371
|
+
for (const n of step.notes) console.log(`- ${n}`);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
console.log(`\n# ${step.name}\n`);
|
|
375
|
+
if ('notes' in step && step.notes?.length) {
|
|
376
|
+
for (const n of step.notes) console.log(`- ${n}`);
|
|
377
|
+
console.log('');
|
|
378
|
+
}
|
|
379
|
+
console.log(step.sparql.trim());
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
main().catch((err: unknown) => {
|
|
384
|
+
console.error(err);
|
|
385
|
+
|
|
386
|
+
process.exit(1);
|
|
387
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import type { ReadCacheOperation } from '../SklEngineOptions';
|
|
3
|
+
|
|
4
|
+
export interface BuildReadCacheKeyInput {
|
|
5
|
+
operation: ReadCacheOperation;
|
|
6
|
+
args: readonly unknown[];
|
|
7
|
+
endpointUrl?: string;
|
|
8
|
+
namespace?: string;
|
|
9
|
+
keyHint?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function stableStringify(value: unknown): string {
|
|
13
|
+
if (value === null) return 'null';
|
|
14
|
+
|
|
15
|
+
const valueType = typeof value;
|
|
16
|
+
if (valueType === 'number' || valueType === 'boolean') return String(value);
|
|
17
|
+
if (valueType === 'string') return JSON.stringify(value);
|
|
18
|
+
if (valueType !== 'object') {
|
|
19
|
+
const serializedPrimitive = JSON.stringify(value);
|
|
20
|
+
return serializedPrimitive ?? String(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const objectValue = value as Record<string, unknown>;
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/require-array-sort-compare
|
|
29
|
+
const keys = Object.keys(objectValue).sort();
|
|
30
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(objectValue[key])}`).join(',')}}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildReadCacheKey(input: BuildReadCacheKeyInput): string {
|
|
34
|
+
const keyParts = {
|
|
35
|
+
namespace: input.namespace ?? '',
|
|
36
|
+
endpointUrl: input.endpointUrl ?? '',
|
|
37
|
+
operation: input.operation,
|
|
38
|
+
keyHint: input.keyHint ?? '',
|
|
39
|
+
args: input.args
|
|
40
|
+
};
|
|
41
|
+
return crypto.createHash('sha256').update(stableStringify(keyParts)).digest('hex');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class ReadCacheSingleflight {
|
|
45
|
+
private readonly inflight = new Map<string, Promise<unknown>>();
|
|
46
|
+
|
|
47
|
+
public async do<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
48
|
+
const existing = this.inflight.get(key) as Promise<T> | undefined;
|
|
49
|
+
if (existing) {
|
|
50
|
+
return existing;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const promise = (async(): Promise<T> => {
|
|
54
|
+
try {
|
|
55
|
+
return await fn();
|
|
56
|
+
} finally {
|
|
57
|
+
this.inflight.delete(key);
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
|
|
61
|
+
this.inflight.set(key, promise as Promise<unknown>);
|
|
62
|
+
return promise;
|
|
63
|
+
}
|
|
64
|
+
}
|