@edgedev/template-engine 0.1.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.
@@ -0,0 +1,753 @@
1
+ import { ARRAY_BLOCK_RE, parseConfig } from './templateConfig'
2
+ import {
3
+ createKvIndexClient,
4
+ type KvIndexClientOptions,
5
+ type KvIndexRecord,
6
+ } from './kvIndexClient'
7
+ import type {
8
+ CollectionDefinition,
9
+ QueryFilter,
10
+ QueryItemsInput,
11
+ QueryOrder,
12
+ TemplateMeta,
13
+ TemplateValues,
14
+ } from './types'
15
+
16
+ export interface HydrateValuesOptions {
17
+ content: string
18
+ meta?: TemplateMeta
19
+ values?: TemplateValues
20
+ uniqueKey: string
21
+ clientOptions: KvIndexClientOptions
22
+ }
23
+
24
+ interface RawCollectionConfig {
25
+ field: string
26
+ collection?: CollectionDefinition
27
+ queryItems?: QueryItemsInput
28
+ queryOptions?: QueryFilter[]
29
+ limit?: number | string | null
30
+ api?: string
31
+ apiField?: string
32
+ apiQuery?: unknown
33
+ value?: unknown
34
+ }
35
+
36
+ interface QueryItem {
37
+ searchKey: string
38
+ searchValue: string | string[]
39
+ }
40
+
41
+ interface NormalizedCollectionConfig {
42
+ field: string
43
+ baseKey?: string
44
+ apiUrl?: string
45
+ apiField?: string
46
+ queryItems: QueryItem[]
47
+ filters: QueryFilter[]
48
+ orders: QueryOrder[]
49
+ limit?: number
50
+ fallbackValue?: unknown
51
+ }
52
+
53
+ const isRecord = (input: unknown): input is Record<string, unknown> => {
54
+ return !!input && typeof input === 'object' && !Array.isArray(input)
55
+ }
56
+
57
+ const getValueAtPath = (record: unknown, path: string): unknown => {
58
+ if (!isRecord(record) || !path)
59
+ return undefined
60
+ const keys = path.split('.')
61
+ let current: unknown = record
62
+ for (const key of keys) {
63
+ if (!isRecord(current) || !(key in current))
64
+ return undefined
65
+ current = current[key]
66
+ }
67
+ return current
68
+ }
69
+
70
+ const normalizeLimit = (value: unknown): number | undefined => {
71
+ if (value == null)
72
+ return undefined
73
+ const num = typeof value === 'number' ? value : Number(value)
74
+ if (!Number.isFinite(num) || num <= 0)
75
+ return undefined
76
+ return Math.floor(num)
77
+ }
78
+
79
+ const pickString = (value: unknown): string | undefined => {
80
+ if (typeof value !== 'string')
81
+ return undefined
82
+ const trimmed = value.trim()
83
+ return trimmed.length > 0 ? trimmed : undefined
84
+ }
85
+
86
+ const normalizeSearchValue = (value: unknown): string | string[] | undefined => {
87
+ if (value == null)
88
+ return undefined
89
+
90
+ if (Array.isArray(value)) {
91
+ const normalized = value
92
+ .map((entry) => {
93
+ if (entry == null)
94
+ return undefined
95
+ if (typeof entry === 'string')
96
+ return entry
97
+ if (typeof entry === 'number' || typeof entry === 'boolean')
98
+ return String(entry)
99
+ if (typeof entry === 'object')
100
+ return JSON.stringify(entry)
101
+ return String(entry)
102
+ })
103
+ .filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
104
+ return normalized.length > 0 ? normalized : undefined
105
+ }
106
+
107
+ if (typeof value === 'string')
108
+ return value
109
+ if (typeof value === 'number' || typeof value === 'boolean')
110
+ return String(value)
111
+ if (typeof value === 'object')
112
+ return JSON.stringify(value)
113
+
114
+ return String(value)
115
+ }
116
+
117
+ const normalizeQueryItems = (
118
+ input: QueryItemsInput,
119
+ fallbackField?: string,
120
+ ): QueryItem[] => {
121
+ const items: QueryItem[] = []
122
+
123
+ const pushItem = (key: unknown, rawValue: unknown): void => {
124
+ if (typeof key !== 'string' || !key.trim())
125
+ return
126
+ const normalizedValue = normalizeSearchValue(rawValue)
127
+ if (normalizedValue == null)
128
+ return
129
+ items.push({
130
+ searchKey: key.trim(),
131
+ searchValue: normalizedValue,
132
+ })
133
+ }
134
+
135
+ if (!input)
136
+ return items
137
+
138
+ if (typeof input === 'string') {
139
+ if (fallbackField)
140
+ pushItem(fallbackField, input)
141
+ return items
142
+ }
143
+
144
+ if (Array.isArray(input)) {
145
+ const allStrings = input.every(entry => typeof entry === 'string')
146
+ if (allStrings) {
147
+ if (fallbackField) {
148
+ for (const value of input)
149
+ pushItem(fallbackField, value)
150
+ }
151
+ else {
152
+ for (let i = 0; i < input.length - 1; i += 2)
153
+ pushItem(input[i], input[i + 1])
154
+ }
155
+ return items
156
+ }
157
+
158
+ for (const entry of input) {
159
+ if (Array.isArray(entry)) {
160
+ const [key, value] = entry
161
+ pushItem(key, value)
162
+ continue
163
+ }
164
+ if (typeof entry === 'string') {
165
+ if (fallbackField)
166
+ pushItem(fallbackField, entry)
167
+ continue
168
+ }
169
+ if (isRecord(entry)) {
170
+ let key = entry.searchKey ?? entry.field ?? entry.key
171
+ let value = entry.searchValue ?? entry.value ?? entry.values ?? entry.options
172
+
173
+ if ((!key || typeof key !== 'string') && value === undefined) {
174
+ const owned = Object.entries(entry)
175
+ if (owned.length === 1) {
176
+ const [derivedKey, derivedValue] = owned[0]
177
+ key = derivedKey
178
+ value = derivedValue
179
+ }
180
+ }
181
+
182
+ pushItem(key, value)
183
+ }
184
+ }
185
+ return items
186
+ }
187
+
188
+ if (isRecord(input)) {
189
+ for (const [key, value] of Object.entries(input))
190
+ pushItem(key, value)
191
+ }
192
+
193
+ return items
194
+ }
195
+
196
+ const buildApiUrl = (inputUrl: string, queryItems: QueryItem[]): string => {
197
+ if (!inputUrl || !queryItems.length)
198
+ return inputUrl
199
+
200
+ const hashIndex = inputUrl.indexOf('#')
201
+ const hash = hashIndex >= 0 ? inputUrl.slice(hashIndex) : ''
202
+ const beforeHash = hashIndex >= 0 ? inputUrl.slice(0, hashIndex) : inputUrl
203
+
204
+ const queryIndex = beforeHash.indexOf('?')
205
+ const path = queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash
206
+ const existingQuery = queryIndex >= 0 ? beforeHash.slice(queryIndex + 1) : ''
207
+
208
+ const params = new URLSearchParams(existingQuery)
209
+
210
+ for (const { searchKey, searchValue } of queryItems) {
211
+ if (!searchKey)
212
+ continue
213
+
214
+ const values = Array.isArray(searchValue) ? searchValue : [searchValue]
215
+ if (!values.length)
216
+ continue
217
+
218
+ params.delete(searchKey)
219
+ for (const value of values)
220
+ params.append(searchKey, value)
221
+ }
222
+
223
+ const queryString = params.toString()
224
+ if (!queryString)
225
+ return `${path}${hash}`
226
+ return `${path}?${queryString}${hash}`
227
+ }
228
+
229
+ const dedupeByCanonical = (records: KvIndexRecord[]): KvIndexRecord[] => {
230
+ const seen = new Set<string>()
231
+ const result: KvIndexRecord[] = []
232
+ for (const record of records) {
233
+ const canonical = getValueAtPath(record, 'canonical')
234
+ if (typeof canonical === 'string') {
235
+ if (seen.has(canonical))
236
+ continue
237
+ seen.add(canonical)
238
+ }
239
+ result.push(record)
240
+ }
241
+ return result
242
+ }
243
+
244
+ const fetchApiRecords = async (
245
+ apiUrl: string,
246
+ queryItems: QueryItem[],
247
+ options: {
248
+ apiField?: string
249
+ fetchImpl?: KvIndexClientOptions['fetch']
250
+ },
251
+ ): Promise<KvIndexRecord[]> => {
252
+ const { apiField, fetchImpl } = options
253
+ const urlToRequest = buildApiUrl(apiUrl, queryItems)
254
+ const fetchFn = fetchImpl ?? (globalThis.fetch as KvIndexClientOptions['fetch'] | undefined)
255
+
256
+ if (!fetchFn) {
257
+ console.warn('[hydrateValues] No fetch implementation available for API source', { url: apiUrl })
258
+ return []
259
+ }
260
+
261
+ try {
262
+ const response = await fetchFn(urlToRequest)
263
+ if (!response.ok) {
264
+ console.warn('[hydrateValues] API request failed', { url: urlToRequest, status: response.status })
265
+ return []
266
+ }
267
+
268
+ const rawData = await response.json()
269
+ const resolved = apiField ? (getValueAtPath(rawData, apiField) ?? rawData) : rawData
270
+
271
+ if (Array.isArray(resolved))
272
+ return resolved.filter(isRecord) as KvIndexRecord[]
273
+
274
+ if (isRecord(resolved))
275
+ return [resolved as KvIndexRecord]
276
+
277
+ return []
278
+ }
279
+ catch (error) {
280
+ console.warn('[hydrateValues] Failed to fetch API source', { url: apiUrl, error })
281
+ return []
282
+ }
283
+ }
284
+
285
+ const toComparableNumber = (value: unknown): number | null => {
286
+ if (typeof value === 'number')
287
+ return value
288
+ if (typeof value === 'string') {
289
+ const num = Number(value)
290
+ return Number.isFinite(num) ? num : null
291
+ }
292
+ if (typeof value === 'boolean')
293
+ return value ? 1 : 0
294
+ return null
295
+ }
296
+
297
+ const compareValues = (left: unknown, right: unknown): number => {
298
+ if (left == null && right == null)
299
+ return 0
300
+ if (left == null)
301
+ return -1
302
+ if (right == null)
303
+ return 1
304
+
305
+ const leftNumber = toComparableNumber(left)
306
+ const rightNumber = toComparableNumber(right)
307
+ if (leftNumber != null && rightNumber != null)
308
+ return leftNumber - rightNumber
309
+
310
+ const leftString = String(left)
311
+ const rightString = String(right)
312
+ return leftString.localeCompare(rightString, undefined, { numeric: true, sensitivity: 'base' })
313
+ }
314
+
315
+ const ensureArray = (value: unknown): unknown[] => {
316
+ if (Array.isArray(value))
317
+ return value
318
+ if (value == null)
319
+ return []
320
+ return [value]
321
+ }
322
+
323
+ const matchesFilter = (record: KvIndexRecord, filter: QueryFilter): boolean => {
324
+ if (!filter || typeof filter.field !== 'string')
325
+ return true
326
+
327
+ const field = filter.field.trim()
328
+ if (!field)
329
+ return true
330
+
331
+ const left = getValueAtPath(record, field)
332
+ const operator = (filter.operator ?? '==').toLowerCase()
333
+ const singleValue = filter.value ?? (Array.isArray(filter.values) ? filter.values[0] : undefined)
334
+ const arrayValues = Array.isArray(filter.values)
335
+ ? filter.values
336
+ : Array.isArray(filter.options)
337
+ ? ensureArray(filter.options)
338
+ : Array.isArray(filter.value)
339
+ ? ensureArray(filter.value)
340
+ : undefined
341
+
342
+ switch (operator) {
343
+ case '==':
344
+ case '=':
345
+ case '===':
346
+ case 'eq':
347
+ return left === singleValue
348
+ case '!=':
349
+ case '!==':
350
+ case '<>':
351
+ case 'neq':
352
+ return left !== singleValue
353
+ case '>':
354
+ case 'gt':
355
+ return compareValues(left, singleValue) > 0
356
+ case '>=':
357
+ case 'gte':
358
+ return compareValues(left, singleValue) >= 0
359
+ case '<':
360
+ case 'lt':
361
+ return compareValues(left, singleValue) < 0
362
+ case '<=':
363
+ case 'lte':
364
+ return compareValues(left, singleValue) <= 0
365
+ case 'in': {
366
+ const candidates = arrayValues ?? ensureArray(filter.value)
367
+ return candidates.some(candidate => candidate === left)
368
+ }
369
+ case 'not-in': {
370
+ const candidates = arrayValues ?? ensureArray(filter.value)
371
+ return !candidates.some(candidate => candidate === left)
372
+ }
373
+ case 'array-contains':
374
+ case 'contains': {
375
+ if (!Array.isArray(left))
376
+ return false
377
+ return left.some(entry => entry === singleValue)
378
+ }
379
+ case 'array-contains-any': {
380
+ if (!Array.isArray(left))
381
+ return false
382
+ const candidates = arrayValues ?? ensureArray(filter.value)
383
+ return candidates.some(candidate => left.some(entry => entry === candidate))
384
+ }
385
+ case 'array-contains-all': {
386
+ if (!Array.isArray(left))
387
+ return false
388
+ const candidates = arrayValues ?? ensureArray(filter.value)
389
+ return candidates.every(candidate => left.some(entry => entry === candidate))
390
+ }
391
+ case 'exists':
392
+ return left != null
393
+ case 'not-exists':
394
+ case 'missing':
395
+ return left == null
396
+ default:
397
+ return true
398
+ }
399
+ }
400
+
401
+ const applyFilters = (records: KvIndexRecord[], filters: QueryFilter[]): KvIndexRecord[] => {
402
+ if (!filters.length)
403
+ return records
404
+ return records.filter(record => filters.every(filter => matchesFilter(record, filter)))
405
+ }
406
+
407
+ const applyOrdering = (records: KvIndexRecord[], orders: QueryOrder[]): KvIndexRecord[] => {
408
+ if (!orders.length)
409
+ return records
410
+
411
+ const validOrders = orders
412
+ .filter(order => order && typeof order.field === 'string' && order.field.trim().length > 0)
413
+
414
+ if (!validOrders.length)
415
+ return records
416
+
417
+ const sorted = [...records]
418
+ sorted.sort((a, b) => {
419
+ for (const order of validOrders) {
420
+ const direction = (order.direction ?? 'asc').toLowerCase() === 'desc' ? -1 : 1
421
+ const comparison = compareValues(
422
+ getValueAtPath(a, order.field!),
423
+ getValueAtPath(b, order.field!),
424
+ )
425
+ if (comparison !== 0)
426
+ return comparison * direction
427
+ }
428
+ return 0
429
+ })
430
+ return sorted
431
+ }
432
+
433
+ const applyLimit = (records: KvIndexRecord[], limit: number | undefined): KvIndexRecord[] => {
434
+ if (!limit || limit <= 0)
435
+ return records
436
+ return records.slice(0, limit)
437
+ }
438
+
439
+ const limitArrayValue = (value: unknown, limit: number | undefined): unknown => {
440
+ if (!Array.isArray(value))
441
+ return value
442
+ if (!limit || limit <= 0)
443
+ return [...value]
444
+ return (value as unknown[]).slice(0, limit)
445
+ }
446
+
447
+ const resolveBaseKey = (collection: CollectionDefinition, fallbackField: string): string | null => {
448
+ const candidates = [
449
+ typeof collection.baseKey === 'string' ? collection.baseKey.trim() : undefined,
450
+ typeof collection.path === 'string' ? collection.path.trim() : undefined,
451
+ fallbackField,
452
+ ]
453
+
454
+ for (const candidate of candidates) {
455
+ if (candidate && candidate.length)
456
+ return candidate
457
+ }
458
+
459
+ return null
460
+ }
461
+
462
+ const extractTemplateConfigs = (content: string): Map<string, RawCollectionConfig> => {
463
+ const configs = new Map<string, RawCollectionConfig>()
464
+ if (!content)
465
+ return configs
466
+
467
+ const arrayBlockRegex = new RegExp(ARRAY_BLOCK_RE.source, ARRAY_BLOCK_RE.flags)
468
+ for (const match of content.matchAll(arrayBlockRegex)) {
469
+ const json = match[1]
470
+ const parsed = parseConfig(json) ?? {}
471
+ if (!isRecord(parsed))
472
+ continue
473
+
474
+ const fieldRaw = parsed.field
475
+ if (typeof fieldRaw !== 'string' || !fieldRaw.trim())
476
+ continue
477
+
478
+ const field = fieldRaw.trim()
479
+ const config: RawCollectionConfig = { field }
480
+
481
+ if (parsed.collection && isRecord(parsed.collection))
482
+ config.collection = parsed.collection as CollectionDefinition
483
+
484
+ if ('queryItems' in parsed)
485
+ config.queryItems = parsed.queryItems as QueryItemsInput
486
+
487
+ // if (Array.isArray(parsed.queryOptions))
488
+ // config.queryOptions = parsed.queryOptions as QueryFilter[]
489
+
490
+ if ('limit' in parsed)
491
+ config.limit = parsed.limit as number | string | null
492
+
493
+ if (typeof parsed.api === 'string')
494
+ config.api = parsed.api
495
+
496
+ if (typeof parsed.apiField === 'string')
497
+ config.apiField = parsed.apiField
498
+
499
+ if ('apiQuery' in parsed)
500
+ config.apiQuery = parsed.apiQuery
501
+
502
+ if ('value' in parsed)
503
+ config.value = parsed.value
504
+
505
+ configs.set(field, config)
506
+ }
507
+
508
+ return configs
509
+ }
510
+
511
+ const extractMetaConfigs = (meta?: TemplateMeta): Map<string, RawCollectionConfig> => {
512
+ const configs = new Map<string, RawCollectionConfig>()
513
+ if (!meta)
514
+ return configs
515
+
516
+ for (const [field, entry] of Object.entries(meta)) {
517
+ if (!entry)
518
+ continue
519
+
520
+ const trimmedField = field.trim()
521
+ if (!trimmedField)
522
+ continue
523
+
524
+ const config: RawCollectionConfig = { field: trimmedField }
525
+
526
+ if (entry.collection && isRecord(entry.collection))
527
+ config.collection = entry.collection as CollectionDefinition
528
+
529
+ if ('queryItems' in entry)
530
+ config.queryItems = entry.queryItems as QueryItemsInput
531
+
532
+ // if (Array.isArray(entry.queryOptions))
533
+ // config.queryOptions = entry.queryOptions as QueryFilter[]
534
+
535
+ if ('limit' in entry)
536
+ config.limit = entry.limit as number | string | null
537
+
538
+ if (typeof entry.api === 'string')
539
+ config.api = entry.api
540
+
541
+ if (typeof entry.apiField === 'string')
542
+ config.apiField = entry.apiField
543
+
544
+ if ('apiQuery' in entry)
545
+ config.apiQuery = entry.apiQuery
546
+
547
+ if ('value' in entry)
548
+ config.value = entry.value
549
+
550
+ configs.set(trimmedField, config)
551
+ }
552
+
553
+ return configs
554
+ }
555
+
556
+ const mergeCollections = (
557
+ base?: CollectionDefinition,
558
+ override?: CollectionDefinition,
559
+ ): CollectionDefinition | null => {
560
+ if (!base && !override)
561
+ return null
562
+ return {
563
+ path: (override?.path ?? base?.path) ?? undefined,
564
+ baseKey: (override?.baseKey ?? base?.baseKey) ?? undefined,
565
+ query: Array.isArray(override?.query)
566
+ ? override.query
567
+ : Array.isArray(base?.query)
568
+ ? base.query
569
+ : undefined,
570
+ order: Array.isArray(override?.order)
571
+ ? override.order
572
+ : Array.isArray(base?.order)
573
+ ? base.order
574
+ : undefined,
575
+ }
576
+ }
577
+
578
+ const firstOptionField = (options?: QueryFilter[]): string | undefined => {
579
+ if (!Array.isArray(options))
580
+ return undefined
581
+ for (const option of options) {
582
+ if (option && typeof option.field === 'string' && option.field.trim())
583
+ return option.field.trim()
584
+ }
585
+ return undefined
586
+ }
587
+
588
+ const toValidFilters = (...sources: Array<QueryFilter[] | undefined>): QueryFilter[] => {
589
+ const filters: QueryFilter[] = []
590
+ for (const source of sources) {
591
+ if (!Array.isArray(source))
592
+ continue
593
+ for (const entry of source) {
594
+ if (entry && typeof entry.field === 'string' && entry.field.trim())
595
+ filters.push(entry)
596
+ }
597
+ }
598
+ return filters
599
+ }
600
+
601
+ const toValidOrders = (orders?: QueryOrder[]): QueryOrder[] => {
602
+ if (!Array.isArray(orders))
603
+ return []
604
+ return orders
605
+ .filter(order => order && typeof order.field === 'string' && order.field.trim())
606
+ .map(order => ({
607
+ field: order.field.trim(),
608
+ direction: order.direction,
609
+ }))
610
+ }
611
+
612
+ const buildNormalizedConfigs = (
613
+ templateConfigs: Map<string, RawCollectionConfig>,
614
+ metaConfigs: Map<string, RawCollectionConfig>,
615
+ initialValues?: TemplateValues,
616
+ ): NormalizedCollectionConfig[] => {
617
+ const fields = new Set<string>([
618
+ ...templateConfigs.keys(),
619
+ ...metaConfigs.keys(),
620
+ ])
621
+
622
+ const normalized: NormalizedCollectionConfig[] = []
623
+
624
+ for (const field of fields) {
625
+ const base = templateConfigs.get(field)
626
+ const override = metaConfigs.get(field)
627
+
628
+ const collection = mergeCollections(base?.collection, override?.collection)
629
+ const baseKey = collection ? resolveBaseKey(collection, field) ?? undefined : undefined
630
+ const apiUrl = pickString(override?.api ?? base?.api)
631
+ const apiField = pickString(override?.apiField ?? base?.apiField)
632
+
633
+ const selectedQueryItems = override?.queryItems ?? base?.queryItems
634
+ const fallbackField = firstOptionField(override?.queryOptions) ?? firstOptionField(base?.queryOptions)
635
+ const queryItems = normalizeQueryItems(selectedQueryItems, fallbackField)
636
+ const searchKeys = new Set(queryItems.map(item => item.searchKey))
637
+ const filters = collection && baseKey
638
+ ? toValidFilters(collection.query, override?.queryOptions ?? base?.queryOptions)
639
+ .filter(filter => !searchKeys.has(filter.field.trim()))
640
+ : []
641
+ const orders = collection && baseKey
642
+ ? toValidOrders(collection.order)
643
+ : []
644
+ const limit = normalizeLimit(override?.limit ?? base?.limit)
645
+ const fallbackValue = override?.value ?? base?.value ?? initialValues?.[field]
646
+
647
+ if (!baseKey && !apiUrl && fallbackValue === undefined)
648
+ continue
649
+
650
+ normalized.push({
651
+ field,
652
+ baseKey,
653
+ apiUrl,
654
+ apiField,
655
+ queryItems,
656
+ filters,
657
+ orders,
658
+ limit,
659
+ fallbackValue,
660
+ })
661
+ }
662
+
663
+ return normalized
664
+ }
665
+
666
+ export const hydrateValues = async (
667
+ options: HydrateValuesOptions,
668
+ ): Promise<TemplateValues> => {
669
+ const {
670
+ content,
671
+ meta,
672
+ values,
673
+ uniqueKey,
674
+ clientOptions,
675
+ } = options
676
+ const templateConfigs = extractTemplateConfigs(content)
677
+ const metaConfigs = extractMetaConfigs(meta)
678
+ const normalizedConfigs = buildNormalizedConfigs(templateConfigs, metaConfigs, values)
679
+
680
+
681
+ const resultValues: TemplateValues = { ...(values ?? {}) }
682
+
683
+ if (!normalizedConfigs.length)
684
+ return resultValues
685
+
686
+ const kvClient = createKvIndexClient(clientOptions)
687
+ const apiFetch = clientOptions.fetch ?? (globalThis.fetch as KvIndexClientOptions['fetch'] | undefined)
688
+
689
+ for (const config of normalizedConfigs) {
690
+ const hasApiSource = !!(config.apiUrl && config.apiUrl.length)
691
+ const canQueryCollection = !!config.baseKey
692
+
693
+ if (!hasApiSource && !canQueryCollection) {
694
+ if (config.fallbackValue !== undefined) {
695
+ resultValues[config.field] = Array.isArray(config.fallbackValue)
696
+ ? limitArrayValue(config.fallbackValue, config.limit)
697
+ : config.fallbackValue
698
+ }
699
+ continue
700
+ }
701
+
702
+ let records: KvIndexRecord[] = []
703
+
704
+ if (hasApiSource && config.apiUrl) {
705
+ records = await fetchApiRecords(config.apiUrl, config.queryItems, {
706
+ apiField: config.apiField,
707
+ fetchImpl: apiFetch,
708
+ })
709
+ }
710
+ else if (canQueryCollection && config.baseKey) {
711
+ const collected: KvIndexRecord[] = []
712
+ const queryTargets = config.queryItems.length
713
+ ? config.queryItems
714
+ : [undefined]
715
+
716
+ for (const item of queryTargets) {
717
+ const enrichedParams = {
718
+ baseKey: config.baseKey,
719
+ uniqueKey,
720
+ ...(item?.searchKey ? { searchKey: item.searchKey } : {}),
721
+ ...(item?.searchValue !== undefined ? { searchValue: item.searchValue } : {}),
722
+ }
723
+
724
+ const queried = await kvClient.queryIndex(enrichedParams)
725
+
726
+ if (Array.isArray(queried))
727
+ collected.push(...queried)
728
+ }
729
+ records = collected
730
+ }
731
+
732
+ if (!records.length) {
733
+ if (config.fallbackValue !== undefined) {
734
+ resultValues[config.field] = Array.isArray(config.fallbackValue)
735
+ ? limitArrayValue(config.fallbackValue, config.limit)
736
+ : config.fallbackValue
737
+ }
738
+ else {
739
+ resultValues[config.field] = []
740
+ }
741
+ continue
742
+ }
743
+
744
+ const deduped = dedupeByCanonical(records)
745
+ const filtered = applyFilters(deduped, config.filters)
746
+ const ordered = applyOrdering(filtered, config.orders)
747
+ const limited = applyLimit(ordered, config.limit)
748
+
749
+ resultValues[config.field] = limited
750
+ }
751
+
752
+ return resultValues
753
+ }