jtlt 0.13.0 → 0.15.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/.c8rc.json +1 -0
- package/CHANGES.md +18 -0
- package/README.md +99 -85
- package/demo/calltemplate-params-demo.js +13 -22
- package/demo/codemirror.esm.js +50 -15
- package/demo/vendor/jhtml/src/SAJJ/SAJJ.ObjectArrayDelegator.js +17 -17
- package/demo/vendor/jhtml/src/SAJJ/SAJJ.js +60 -61
- package/demo/vendor/jhtml/src/jhtml-browser.js +1 -0
- package/demo/vendor/jhtml/src/jhtml-node.js +1 -0
- package/demo/vendor/jhtml/src/jhtml.js +18 -15
- package/dist/AbstractJoiningTransformer.d.ts +5 -5
- package/dist/AbstractJoiningTransformer.d.ts.map +1 -1
- package/dist/JSONPathTransformer.d.ts +13 -12
- package/dist/JSONPathTransformer.d.ts.map +1 -1
- package/dist/JSONPathTransformerContext.d.ts +60 -20
- package/dist/JSONPathTransformerContext.d.ts.map +1 -1
- package/dist/XPathTransformer.d.ts +9 -2
- package/dist/XPathTransformer.d.ts.map +1 -1
- package/dist/XPathTransformerContext.d.ts +56 -9
- package/dist/XPathTransformerContext.d.ts.map +1 -1
- package/dist/context-extensions.d.ts +24 -0
- package/dist/extendContext.d.ts +10 -0
- package/dist/extendContext.d.ts.map +1 -0
- package/dist/index.d.ts +121 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/indexedDB.d.ts +107 -0
- package/dist/indexedDB.d.ts.map +1 -0
- package/dist/maybeAsync.d.ts +15 -0
- package/dist/maybeAsync.d.ts.map +1 -0
- package/docs/API.expanded.md +70 -20
- package/docs/API.md +34 -4
- package/docs/TO-DO.md +17 -3
- package/eslint.config.js +11 -3
- package/package.json +22 -15
- package/pnpm-workspace.yaml +9 -0
- package/src/AbstractJoiningTransformer.js +3 -3
- package/src/JSONPathTransformer.js +48 -8
- package/src/JSONPathTransformerContext.js +175 -25
- package/src/XPathTransformer.js +33 -1
- package/src/XPathTransformerContext.js +145 -16
- package/src/context-extensions.d.ts +24 -0
- package/src/extendContext.js +21 -0
- package/src/index.js +102 -11
- package/src/indexedDB.js +530 -0
- package/src/maybeAsync.js +44 -0
- package/tsconfig.json +2 -2
package/src/indexedDB.js
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import {JSONPath as jsonpath} from 'jsonpath-plus';
|
|
2
|
+
import fontoxpath from 'fontoxpath';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {typeof import("idb").openDB} OpenDBFunction
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Memoized loader for the `idb` package's `openDB`, installing the
|
|
10
|
+
* `indexeddbshim` polyfill first when running outside a browser (e.g. Node).
|
|
11
|
+
* Held on an object so the memo write is a property assignment rather than a
|
|
12
|
+
* reassignment of a module-level binding.
|
|
13
|
+
* @type {{promise: Promise<OpenDBFunction> | undefined}}
|
|
14
|
+
*/
|
|
15
|
+
const openDBLoader = {promise: undefined};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @returns {Promise<OpenDBFunction>}
|
|
19
|
+
*/
|
|
20
|
+
function getOpenDB () {
|
|
21
|
+
openDBLoader.promise ||= (async () => {
|
|
22
|
+
/* c8 ignore next -- browser vs. Node branch; one runs per environment */
|
|
23
|
+
if (typeof window === 'undefined' || typeof indexedDB === 'undefined') {
|
|
24
|
+
const {default: setGlobalVars} = await import('indexeddbshim');
|
|
25
|
+
setGlobalVars(globalThis, {
|
|
26
|
+
checkOrigin: false, memoryDatabase: ''
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const idb = await import('idb');
|
|
30
|
+
return idb.openDB;
|
|
31
|
+
})();
|
|
32
|
+
return openDBLoader.promise;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What each matched record contributes to the returned array. Mirrors
|
|
37
|
+
* `jsonpath-plus`'s `resultType`.
|
|
38
|
+
* - `'value'` (default): the stored record.
|
|
39
|
+
* - `'primaryKey'`: the object store's primary key.
|
|
40
|
+
* - `'key'`: the query key — the same as the primary key for a store query,
|
|
41
|
+
* or the indexed field's value when `index` is set.
|
|
42
|
+
* - `'all'`: a `{key, primaryKey, value}` object.
|
|
43
|
+
* @typedef {'value' | 'key' | 'primaryKey' | 'all'} IndexedDBResultType
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {object} QueryOptions
|
|
48
|
+
* @property {string} [index]
|
|
49
|
+
* @property {{
|
|
50
|
+
* lower?: IDBValidKey, upper?: IDBValidKey,
|
|
51
|
+
* lowerOpen?: boolean, upperOpen?: boolean
|
|
52
|
+
* }} [range]
|
|
53
|
+
* @property {NonNullable<object|string|number>} [query]
|
|
54
|
+
* @property {'next' | 'nextunique' | 'prev' | 'prevunique'} [direction]
|
|
55
|
+
* @property {number} [count]
|
|
56
|
+
* @property {IndexedDBResultType} [resultType]
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One entry of a cursor walk (as wrapped by `idb`, so `continue()` is async).
|
|
61
|
+
* @typedef {object} IdbCursorLike
|
|
62
|
+
* @property {IDBValidKey} key
|
|
63
|
+
* @property {IDBValidKey} primaryKey
|
|
64
|
+
* @property {unknown} value
|
|
65
|
+
* @property {() => Promise<IdbCursorLike | null>} continue
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A `{key, primaryKey, value}` triple for a single matched entry, as returned
|
|
70
|
+
* by `queryIndexedDB(..., {resultType: 'all'})`.
|
|
71
|
+
* @typedef {{
|
|
72
|
+
* key: IDBValidKey, primaryKey: IDBValidKey, value: unknown
|
|
73
|
+
* }} IdbRecord
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The subset of an IndexedDB object store / index (as wrapped by `idb`, so
|
|
78
|
+
* the request methods resolve Promises) that {@link queryIndexedDB} calls.
|
|
79
|
+
* @typedef {object} IdbQueryTarget
|
|
80
|
+
* @property {(
|
|
81
|
+
* range?: IDBKeyRange | null, direction?: IDBCursorDirection
|
|
82
|
+
* ) => Promise<IdbCursorLike | null>} openCursor
|
|
83
|
+
* @property {(
|
|
84
|
+
* range?: IDBKeyRange | null, direction?: IDBCursorDirection
|
|
85
|
+
* ) => Promise<IdbCursorLike | null>} openKeyCursor
|
|
86
|
+
* @property {(
|
|
87
|
+
* range?: IDBKeyRange | null, count?: number
|
|
88
|
+
* ) => Promise<unknown[]>} getAll
|
|
89
|
+
* @property {(
|
|
90
|
+
* range?: IDBKeyRange | null, count?: number
|
|
91
|
+
* ) => Promise<IDBValidKey[]>} getAllKeys
|
|
92
|
+
* @property {((options?: {
|
|
93
|
+
* query?: IDBKeyRange | null,
|
|
94
|
+
* count?: number,
|
|
95
|
+
* direction?: IDBCursorDirection
|
|
96
|
+
* }) => Promise<IdbRecord[]>)} [getAllRecords]
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Walk a cursor over `target`, collecting `project(cursor)` for each entry.
|
|
101
|
+
* @param {IdbQueryTarget} target - An object store or index
|
|
102
|
+
* @param {IDBKeyRange|null} range
|
|
103
|
+
* @param {IDBCursorDirection|undefined} direction
|
|
104
|
+
* @param {number|undefined} count
|
|
105
|
+
* @param {(cursor: IdbCursorLike) => unknown} project
|
|
106
|
+
* @param {boolean} keyOnly - Open a key-only cursor (skips reading records)
|
|
107
|
+
* @returns {Promise<unknown[]>}
|
|
108
|
+
*/
|
|
109
|
+
async function collectViaCursor (
|
|
110
|
+
target, range, direction, count, project, keyOnly
|
|
111
|
+
) {
|
|
112
|
+
const results = [];
|
|
113
|
+
let cursor = await (keyOnly
|
|
114
|
+
? target.openKeyCursor(range, direction)
|
|
115
|
+
: target.openCursor(range, direction));
|
|
116
|
+
// eslint-disable-next-line @stylistic/max-len -- Long
|
|
117
|
+
// eslint-disable-next-line no-unmodified-loop-condition -- `results` grows and `cursor` advances each iteration
|
|
118
|
+
while (cursor && (!count || results.length < count)) {
|
|
119
|
+
results.push(project(cursor));
|
|
120
|
+
// eslint-disable-next-line no-await-in-loop -- sequential cursor walk
|
|
121
|
+
cursor = await cursor.continue();
|
|
122
|
+
}
|
|
123
|
+
return results;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Read every matched entry as an {@link IdbRecord}, preferring the newer bulk
|
|
128
|
+
* `getAllRecords()` API and falling back to a cursor walk.
|
|
129
|
+
* @param {IdbQueryTarget} target - An object store or index
|
|
130
|
+
* @param {IDBKeyRange|null} range
|
|
131
|
+
* @param {IDBCursorDirection|undefined} direction
|
|
132
|
+
* @param {number|undefined} count
|
|
133
|
+
* @returns {Promise<IdbRecord[]>}
|
|
134
|
+
*/
|
|
135
|
+
async function readAllRecords (target, range, direction, count) {
|
|
136
|
+
if (typeof target.getAllRecords === 'function') {
|
|
137
|
+
const records = await target.getAllRecords({
|
|
138
|
+
query: range ?? undefined, count, direction
|
|
139
|
+
});
|
|
140
|
+
return records.map((record) => ({
|
|
141
|
+
key: record.key, primaryKey: record.primaryKey, value: record.value
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
/* c8 ignore start -- cursor fallback only where getAllRecords is absent */
|
|
145
|
+
return /** @type {Promise<IdbRecord[]>} */ (
|
|
146
|
+
collectViaCursor(target, range, direction, count, (cursor) => ({
|
|
147
|
+
key: cursor.key, primaryKey: cursor.primaryKey, value: cursor.value
|
|
148
|
+
}), false)
|
|
149
|
+
);
|
|
150
|
+
/* c8 ignore stop */
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Read entries from an IndexedDB object store (or one of its indexes). The
|
|
155
|
+
* element type of the resolved array is narrowed by `options.resultType`.
|
|
156
|
+
* @overload
|
|
157
|
+
* @param {string} dbName
|
|
158
|
+
* @param {string} storeName
|
|
159
|
+
* @param {QueryOptions & {resultType: 'all'}} options
|
|
160
|
+
* @returns {Promise<IdbRecord[]>}
|
|
161
|
+
*/
|
|
162
|
+
/**
|
|
163
|
+
* @overload
|
|
164
|
+
* @param {string} dbName
|
|
165
|
+
* @param {string} storeName
|
|
166
|
+
* @param {QueryOptions & {resultType: 'key' | 'primaryKey'}} options
|
|
167
|
+
* @returns {Promise<IDBValidKey[]>}
|
|
168
|
+
*/
|
|
169
|
+
/**
|
|
170
|
+
* @overload
|
|
171
|
+
* @param {string} dbName
|
|
172
|
+
* @param {string} storeName
|
|
173
|
+
* @param {QueryOptions} [options]
|
|
174
|
+
* @returns {Promise<unknown[]>}
|
|
175
|
+
*/
|
|
176
|
+
/**
|
|
177
|
+
* @param {string} dbName
|
|
178
|
+
* @param {string} storeName
|
|
179
|
+
* @param {QueryOptions} [options]
|
|
180
|
+
* @returns {Promise<unknown[]>}
|
|
181
|
+
*/
|
|
182
|
+
export async function queryIndexedDB (dbName, storeName, options = {}) {
|
|
183
|
+
const {resultType = 'value', count, direction} = options;
|
|
184
|
+
const openDB = await getOpenDB();
|
|
185
|
+
const db = await openDB(dbName);
|
|
186
|
+
const tx = db.transaction(storeName, 'readonly');
|
|
187
|
+
const store = tx.objectStore(storeName);
|
|
188
|
+
|
|
189
|
+
const target = /** @type {IdbQueryTarget} */ (/** @type {unknown} */ (
|
|
190
|
+
options.index ? store.index(options.index) : store
|
|
191
|
+
));
|
|
192
|
+
|
|
193
|
+
let range = null;
|
|
194
|
+
if (options.range) {
|
|
195
|
+
range = IDBKeyRange.bound(
|
|
196
|
+
options.range.lower,
|
|
197
|
+
options.range.upper,
|
|
198
|
+
options.range.lowerOpen || false,
|
|
199
|
+
options.range.upperOpen || false
|
|
200
|
+
);
|
|
201
|
+
} else if ('query' in options) {
|
|
202
|
+
range = IDBKeyRange.only(options.query);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const reversed = Boolean(direction && direction.startsWith('prev'));
|
|
206
|
+
|
|
207
|
+
if (resultType === 'all') {
|
|
208
|
+
return readAllRecords(target, range, direction, count);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The index key is only available per-entry via a cursor.
|
|
212
|
+
if (resultType === 'key' && options.index) {
|
|
213
|
+
return collectViaCursor(
|
|
214
|
+
target, range, direction, count, (cursor) => cursor.key, true
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (reversed) {
|
|
219
|
+
// At this point `resultType` is 'value', 'primaryKey', or 'key' without
|
|
220
|
+
// an index; for a store cursor the key and primary key are the same.
|
|
221
|
+
return collectViaCursor(
|
|
222
|
+
target, range, direction, count,
|
|
223
|
+
resultType === 'value'
|
|
224
|
+
? (cursor) => cursor.value
|
|
225
|
+
: (cursor) => cursor.primaryKey,
|
|
226
|
+
resultType !== 'value'
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Forward iteration has bulk APIs. For a store query the primary key and
|
|
231
|
+
// the query key are the same, so `getAllKeys` serves both.
|
|
232
|
+
if (resultType === 'value') {
|
|
233
|
+
return target.getAll(range, count);
|
|
234
|
+
}
|
|
235
|
+
return target.getAllKeys(range, count);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Split a raw argument string (the text between the parentheses of an
|
|
240
|
+
* `indexedDB(...)` call) into its top-level, comma-separated pieces, ignoring
|
|
241
|
+
* commas that appear inside quotes, objects (`{}`), arrays (`[]`) or nested
|
|
242
|
+
* parentheses.
|
|
243
|
+
* @param {string} argStr
|
|
244
|
+
* @returns {string[]}
|
|
245
|
+
*/
|
|
246
|
+
function splitTopLevelArgs (argStr) {
|
|
247
|
+
const pieces = [];
|
|
248
|
+
let current = '';
|
|
249
|
+
let depth = 0;
|
|
250
|
+
/** @type {string|null} */
|
|
251
|
+
let quote = null;
|
|
252
|
+
for (let i = 0; i < argStr.length; i++) {
|
|
253
|
+
const ch = argStr[i];
|
|
254
|
+
if (quote) {
|
|
255
|
+
current += ch;
|
|
256
|
+
if (ch === quote && argStr[i - 1] !== '\\') {
|
|
257
|
+
quote = null;
|
|
258
|
+
}
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (ch === '"' || ch === "'") {
|
|
262
|
+
quote = ch;
|
|
263
|
+
current += ch;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (['{', '[', '('].includes(ch)) {
|
|
267
|
+
depth++;
|
|
268
|
+
} else if (['}', ']', ')'].includes(ch)) {
|
|
269
|
+
depth--;
|
|
270
|
+
}
|
|
271
|
+
if (ch === ',' && depth === 0) {
|
|
272
|
+
pieces.push(current);
|
|
273
|
+
current = '';
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
current += ch;
|
|
277
|
+
}
|
|
278
|
+
if (current.trim() !== '') {
|
|
279
|
+
pieces.push(current);
|
|
280
|
+
}
|
|
281
|
+
return pieces;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Coerce a single raw argument token from an `indexedDB(...)` expression into a
|
|
286
|
+
* JavaScript value. Supports single/double-quoted strings, numbers, the
|
|
287
|
+
* literals `true`/`false`/`null`, and JSON object/array literals.
|
|
288
|
+
* @param {string} raw
|
|
289
|
+
* @returns {unknown}
|
|
290
|
+
*/
|
|
291
|
+
function parseArgValue (raw) {
|
|
292
|
+
const s = raw.trim();
|
|
293
|
+
if (
|
|
294
|
+
(s.startsWith("'") && s.endsWith("'")) ||
|
|
295
|
+
(s.startsWith('"') && s.endsWith('"'))
|
|
296
|
+
) {
|
|
297
|
+
return s.slice(1, -1);
|
|
298
|
+
}
|
|
299
|
+
if (s === 'true') {
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
if (s === 'false') {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
if (s === 'null') {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
if (s !== '' && !Number.isNaN(Number(s))) {
|
|
309
|
+
return Number(s);
|
|
310
|
+
}
|
|
311
|
+
if (s.startsWith('{') || s.startsWith('[')) {
|
|
312
|
+
try {
|
|
313
|
+
return JSON.parse(s);
|
|
314
|
+
} catch {
|
|
315
|
+
// Fall through to returning the raw string for callers to handle.
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return s;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* @typedef {object} ParsedIndexedDBExpression
|
|
323
|
+
* @property {string} dbName
|
|
324
|
+
* @property {string} storeName
|
|
325
|
+
* @property {QueryOptions|undefined} options
|
|
326
|
+
* @property {string} trailing - A JSONPath segment applied to the fetched
|
|
327
|
+
* records (e.g. `.*.name` or `[0].name`), with surrounding whitespace
|
|
328
|
+
* trimmed. Used by the JSONPath engine only; the XPath engine exposes
|
|
329
|
+
* `indexedDB()` as a registered XPath function instead (see
|
|
330
|
+
* {@link evaluateXPathWithIndexedDB}).
|
|
331
|
+
*/
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Detect and parse an `indexedDB(...)` call at the start of a selector string.
|
|
335
|
+
* @param {string} expr
|
|
336
|
+
* @returns {ParsedIndexedDBExpression|null} `null` when `expr` is not an
|
|
337
|
+
* `indexedDB(...)` expression.
|
|
338
|
+
*/
|
|
339
|
+
export function parseIndexedDBExpression (expr) {
|
|
340
|
+
const match = (/^\s*indexedDB\((?<args>.*)\)(?<trailing>.*)$/v).exec(expr);
|
|
341
|
+
if (!match || !match.groups) {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
const args = splitTopLevelArgs(match.groups.args).map(
|
|
345
|
+
(piece) => parseArgValue(piece)
|
|
346
|
+
);
|
|
347
|
+
const [dbName, storeName, options] = args;
|
|
348
|
+
return {
|
|
349
|
+
dbName: /** @type {string} */ (dbName),
|
|
350
|
+
storeName: /** @type {string} */ (storeName),
|
|
351
|
+
options: /** @type {QueryOptions|undefined} */ (
|
|
352
|
+
options && typeof options === 'object' ? options : undefined
|
|
353
|
+
),
|
|
354
|
+
trailing: match.groups.trailing.trim()
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Fetch the records for a parsed `indexedDB(...)` expression and, when a
|
|
360
|
+
* trailing JSONPath segment is present (e.g. `.*.name`), evaluate it against
|
|
361
|
+
* the fetched records. Shared by the JSONPath and XPath engines.
|
|
362
|
+
* @param {ParsedIndexedDBExpression} parsed
|
|
363
|
+
* @param {{preventEval?: boolean}} [options]
|
|
364
|
+
* @returns {Promise<unknown>}
|
|
365
|
+
*/
|
|
366
|
+
export async function resolveIndexedDBQuery (parsed, {preventEval} = {}) {
|
|
367
|
+
const {dbName, storeName, options, trailing} = parsed;
|
|
368
|
+
const data = await queryIndexedDB(dbName, storeName, options);
|
|
369
|
+
if (!trailing) {
|
|
370
|
+
return data;
|
|
371
|
+
}
|
|
372
|
+
// A JSONPath trailing segment always begins with a step (`.` or `[`).
|
|
373
|
+
return /** @type {(config: object) => unknown} */ (jsonpath)({
|
|
374
|
+
path: '$' + trailing,
|
|
375
|
+
json: data,
|
|
376
|
+
preventEval,
|
|
377
|
+
wrap: false,
|
|
378
|
+
returnType: 'value'
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Namespace URI backing the predefined `jtlt` prefix under which the XPath
|
|
384
|
+
* `indexedDB()` function is registered.
|
|
385
|
+
*/
|
|
386
|
+
export const JTLT_XPATH_NAMESPACE = 'urn:jtlt';
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* State for the process-global `indexedDB()` XPath function: the current
|
|
390
|
+
* collect/resolve mode, the pending request list, and the fetched-record
|
|
391
|
+
* cache. Kept on one module-level object so updates are property writes, not
|
|
392
|
+
* rebindings of a module binding.
|
|
393
|
+
*/
|
|
394
|
+
const xpathIndexedDB = {
|
|
395
|
+
registered: false,
|
|
396
|
+
/** @type {'collect'|'resolve'} */
|
|
397
|
+
mode: 'resolve',
|
|
398
|
+
/** @type {{dbName: string, storeName: string, options: unknown}[]} */
|
|
399
|
+
requests: [],
|
|
400
|
+
/** @type {Map<string, unknown[]>} */
|
|
401
|
+
cache: new Map()
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* @param {string} dbName
|
|
406
|
+
* @param {string} storeName
|
|
407
|
+
* @param {unknown} options
|
|
408
|
+
* @returns {string}
|
|
409
|
+
*/
|
|
410
|
+
function xpathCacheKey (dbName, storeName, options) {
|
|
411
|
+
return JSON.stringify([dbName, storeName, options ?? null]);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Implementation shared by both arities of the `jtlt:indexedDB` XPath
|
|
416
|
+
* function. In `collect` mode it records the requested query and returns an
|
|
417
|
+
* empty sequence; in `resolve` mode it returns the pre-fetched records.
|
|
418
|
+
* @param {unknown} _domFacade - fontoxpath dynamic context (unused)
|
|
419
|
+
* @param {string} dbName
|
|
420
|
+
* @param {string} storeName
|
|
421
|
+
* @param {unknown} [options] - An XPath map, surfaced to JS as a plain object
|
|
422
|
+
* @returns {unknown[]}
|
|
423
|
+
*/
|
|
424
|
+
function xpathIndexedDBFunction (_domFacade, dbName, storeName, options) {
|
|
425
|
+
const opts = options ?? undefined;
|
|
426
|
+
if (xpathIndexedDB.mode === 'collect') {
|
|
427
|
+
xpathIndexedDB.requests.push({dbName, storeName, options: opts});
|
|
428
|
+
return [];
|
|
429
|
+
}
|
|
430
|
+
return xpathIndexedDB.cache.get(
|
|
431
|
+
xpathCacheKey(dbName, storeName, opts)
|
|
432
|
+
/* c8 ignore next -- resolve mode always finds the record cached in phase 2 */
|
|
433
|
+
) ?? [];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Register `jtlt:indexedDB(dbName, storeName)` and
|
|
438
|
+
* `jtlt:indexedDB(dbName, storeName, options)` with fontoxpath. Idempotent.
|
|
439
|
+
* @returns {void}
|
|
440
|
+
*/
|
|
441
|
+
function ensureXPathIndexedDBFunction () {
|
|
442
|
+
if (xpathIndexedDB.registered) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
xpathIndexedDB.registered = true;
|
|
446
|
+
fontoxpath.registerCustomXPathFunction(
|
|
447
|
+
{namespaceURI: JTLT_XPATH_NAMESPACE, localName: 'indexedDB'},
|
|
448
|
+
['xs:string', 'xs:string'], 'item()*',
|
|
449
|
+
xpathIndexedDBFunction
|
|
450
|
+
);
|
|
451
|
+
fontoxpath.registerCustomXPathFunction(
|
|
452
|
+
{namespaceURI: JTLT_XPATH_NAMESPACE, localName: 'indexedDB'},
|
|
453
|
+
['xs:string', 'xs:string', 'map(*)'], 'item()*',
|
|
454
|
+
xpathIndexedDBFunction
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* @param {string} prefix
|
|
460
|
+
* @returns {string|null}
|
|
461
|
+
*/
|
|
462
|
+
const xpathNamespaceResolver = (prefix) => {
|
|
463
|
+
/* c8 ignore next -- fontoxpath only resolves the explicit `jtlt` prefix */
|
|
464
|
+
return prefix === 'jtlt' ? JTLT_XPATH_NAMESPACE : null;
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* @param {unknown} expr
|
|
469
|
+
* @returns {boolean} Whether `expr` calls the `indexedDB()` XPath function
|
|
470
|
+
*/
|
|
471
|
+
export function xpathExpressionUsesIndexedDB (expr) {
|
|
472
|
+
return typeof expr === 'string' && (/\bindexedDB\s*\(/v).test(expr);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Evaluate an XPath 3.1 expression that may call `jtlt:indexedDB(...)`,
|
|
477
|
+
* to a string. Because fontoxpath is synchronous, this first evaluates the
|
|
478
|
+
* expression in "collect" mode to discover every `indexedDB()` call, awaits
|
|
479
|
+
* those queries, then evaluates again with the records available.
|
|
480
|
+
* @param {string} selectStr
|
|
481
|
+
* @param {Node} contextNode
|
|
482
|
+
* @returns {Promise<string>}
|
|
483
|
+
*/
|
|
484
|
+
export async function evaluateXPathWithIndexedDB (selectStr, contextNode) {
|
|
485
|
+
ensureXPathIndexedDBFunction();
|
|
486
|
+
const evalOptions = {
|
|
487
|
+
namespaceResolver: xpathNamespaceResolver,
|
|
488
|
+
language: fontoxpath.Language.XPATH_3_1_LANGUAGE
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// Phase 1: discover the queries (the string result is discarded). During
|
|
492
|
+
// this pass `jtlt:indexedDB()` yields an empty sequence, so an otherwise
|
|
493
|
+
// valid expression can still raise a type error here; that is harmless
|
|
494
|
+
// because only the collected request list matters.
|
|
495
|
+
xpathIndexedDB.mode = 'collect';
|
|
496
|
+
xpathIndexedDB.requests = [];
|
|
497
|
+
/* c8 ignore start -- defensive: swallow phase-1 evaluation errors */
|
|
498
|
+
try {
|
|
499
|
+
fontoxpath.evaluateXPathToString(
|
|
500
|
+
selectStr, contextNode, null, null, evalOptions
|
|
501
|
+
);
|
|
502
|
+
} catch {
|
|
503
|
+
// Ignore: only the collected request list matters from this pass.
|
|
504
|
+
}
|
|
505
|
+
/* c8 ignore stop */
|
|
506
|
+
|
|
507
|
+
// Phase 2: fetch each distinct query and cache the records.
|
|
508
|
+
const fetched = new Set();
|
|
509
|
+
await Promise.all(xpathIndexedDB.requests.map(async (req) => {
|
|
510
|
+
const key = xpathCacheKey(req.dbName, req.storeName, req.options);
|
|
511
|
+
if (fetched.has(key)) {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
fetched.add(key);
|
|
515
|
+
xpathIndexedDB.cache.set(
|
|
516
|
+
key,
|
|
517
|
+
await queryIndexedDB(
|
|
518
|
+
req.dbName,
|
|
519
|
+
req.storeName,
|
|
520
|
+
/** @type {QueryOptions|undefined} */ (req.options)
|
|
521
|
+
)
|
|
522
|
+
);
|
|
523
|
+
}));
|
|
524
|
+
|
|
525
|
+
// Phase 3: evaluate for real, now that the records are available.
|
|
526
|
+
xpathIndexedDB.mode = 'resolve';
|
|
527
|
+
return fontoxpath.evaluateXPathToString(
|
|
528
|
+
selectStr, contextNode, null, null, evalOptions
|
|
529
|
+
);
|
|
530
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Iterate over an array, staying fully synchronous while `body` returns
|
|
3
|
+
* synchronously and only chaining Promises once `body` returns a thenable.
|
|
4
|
+
*
|
|
5
|
+
* This lets a nested asynchronous operation (e.g. an
|
|
6
|
+
* `await this.indexedDB(...)` fetch inside a template) suspend the loop,
|
|
7
|
+
* without forcing the common synchronous case through microtasks or
|
|
8
|
+
* breaking DOM-bound consumers.
|
|
9
|
+
* @template T
|
|
10
|
+
* @param {T[]} array
|
|
11
|
+
* @param {(item: T, index: number, arr: T[]) => any} body
|
|
12
|
+
* @returns {Promise<void>|void}
|
|
13
|
+
*/
|
|
14
|
+
export function maybeAsyncLoop (array, body) {
|
|
15
|
+
let i = 0;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Advance past the awaited item and continue looping. Declared once (not
|
|
19
|
+
* inside the loop) so it can be reused as the `.then()` continuation.
|
|
20
|
+
* @returns {Promise<void>|void}
|
|
21
|
+
*/
|
|
22
|
+
function resume () {
|
|
23
|
+
i++;
|
|
24
|
+
return next();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @returns {Promise<void>|void}
|
|
29
|
+
*/
|
|
30
|
+
function next () {
|
|
31
|
+
while (i < array.length) {
|
|
32
|
+
const result = body(array[i], i, array);
|
|
33
|
+
if (result && typeof result.then === 'function') {
|
|
34
|
+
// eslint-disable-next-line @stylistic/max-len -- Long
|
|
35
|
+
// eslint-disable-next-line promise/prefer-await-to-then -- intentional dynamic sync/async
|
|
36
|
+
return result.then(resume);
|
|
37
|
+
}
|
|
38
|
+
i++;
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return next();
|
|
44
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
"types": ["mocha"]
|
|
12
12
|
},
|
|
13
13
|
"include": [
|
|
14
|
-
"*.js", "*.d.ts", "src/**/*.js", "
|
|
15
|
-
"typings/xpath2-js.d.ts"
|
|
14
|
+
"*.js", "*.d.ts", "src/**/*.js", "src/**/*.d.ts", "demo/**/*.js",
|
|
15
|
+
"test/**/*.js", "test/**/*.d.ts", "typings/xpath2-js.d.ts"
|
|
16
16
|
],
|
|
17
17
|
"exclude": ["node_modules", "test.js", "./dist/**/*.js", "demo/codemirror.esm.js", "demo/vendor"]
|
|
18
18
|
}
|