@asteby/metacore-runtime-react 39.2.6 → 39.2.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "39.2.6",
3
+ "version": "39.2.8",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,7 +2,8 @@
2
2
  // runtime-react components (DynamicTable, dialogs, action dispatcher) can
3
3
  // talk to the backend without a bundler alias to `@/lib/api`. Hosts wrap
4
4
  // their app in <ApiProvider value={axiosInstance}> once at the root.
5
- import React, { createContext, useContext } from 'react'
5
+ import React, { createContext, useContext, useMemo } from 'react'
6
+ import { batchGet } from './query-batch'
6
7
 
7
8
  /** Minimal axios-compatible client shape consumed by runtime-react. */
8
9
  export interface ApiClient {
@@ -29,7 +30,14 @@ export function useApi(): ApiClient {
29
30
  if (!ctx) {
30
31
  throw new Error('useApi() requires an <ApiProvider> ancestor. Hosts must inject an axios-like client via runtime-react ApiProvider.')
31
32
  }
32
- return ctx
33
+ // List, metadata and options GETs share one POST /api/q. Mutations stay
34
+ // on the host client. The wrapper is stable while the host client is.
35
+ return useMemo<ApiClient>(() => ({
36
+ get: (url, config) => batchGet(ctx, url, config),
37
+ post: (url, body, config) => ctx.post(url, body, config),
38
+ put: (url, body, config) => ctx.put(url, body, config),
39
+ delete: (url, config) => ctx.delete(url, config),
40
+ }), [ctx])
33
41
  }
34
42
 
35
43
  /** Optional branch context — hosts that support tenant branches can supply
@@ -78,6 +78,7 @@ import {
78
78
  import { OptionsContext } from './options-context'
79
79
  import type { TableMetadata, ApiResponse, ColumnDefinition } from './types'
80
80
  import { getSearchableColumnKeys } from './column-visibility'
81
+ import { visibleRelationInclude } from './list-include'
81
82
  import { useDebouncedValue } from './use-debounced-value'
82
83
  import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context'
83
84
  import { useDynamicRowActions } from './dynamic-row-actions'
@@ -808,6 +809,11 @@ export function DynamicTable({
808
809
  return Array.from(set)
809
810
  }, [hiddenColumns, scopeHiddenColumns])
810
811
 
812
+ const relationInclude = useMemo(
813
+ () => visibleRelationInclude(metadata?.columns, effectiveHiddenColumns),
814
+ [metadata, effectiveHiddenColumns],
815
+ )
816
+
811
817
  const buildFilterParams = useCallback(() => {
812
818
  const params: Record<string, any> = {}
813
819
  if (sorting.length > 0) {
@@ -868,6 +874,7 @@ export function DynamicTable({
868
874
  per_page: pagination.pageSize,
869
875
  ...buildFilterParams(),
870
876
  }
877
+ if (relationInclude) params.include = relationInclude
871
878
  const res = await api.get(endpoint || `/data/${model}`, { params }) as { data: ApiResponse<any[]> }
872
879
  setForbidden(false)
873
880
  if (res.data.success) {
@@ -895,7 +902,7 @@ export function DynamicTable({
895
902
  } finally {
896
903
  setLoadingData(false)
897
904
  }
898
- }, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync])
905
+ }, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync, relationInclude])
899
906
 
900
907
  // Columns whose metadata opts into a footer total (display_config.aggregate
901
908
  // → styleConfig.aggregate). When empty, no footer row is rendered and no
@@ -936,6 +943,7 @@ export function DynamicTable({
936
943
  per_page: infPageSize,
937
944
  ...buildFilterParams(),
938
945
  }
946
+ if (relationInclude) params.include = relationInclude
939
947
  const res = (await api.get(endpoint || `/data/${model}`, {
940
948
  params,
941
949
  })) as { data: ApiResponse<any[]> }
@@ -974,7 +982,7 @@ export function DynamicTable({
974
982
  else setLoadingData(false)
975
983
  }
976
984
  },
977
- [metadata, buildFilterParams, endpoint, model, api, currentBranch?.id, enableUrlSync],
985
+ [metadata, buildFilterParams, endpoint, model, api, currentBranch?.id, enableUrlSync, relationInclude],
978
986
  )
979
987
 
980
988
  // Signature of everything that must reset the incremental list to page 1:
package/src/index.ts CHANGED
@@ -392,6 +392,21 @@ export {
392
392
  AUDIT_SUBTABLE_COLUMN_KEYS,
393
393
  getSearchableColumnKeys,
394
394
  } from './column-visibility'
395
+ export {
396
+ batchGet,
397
+ loadQueryPart,
398
+ nameBatchTokens,
399
+ narrowInToken,
400
+ optionsBatchToken,
401
+ optionsModelFromUrl,
402
+ rememberInRows,
403
+ resetQueryBatchCache,
404
+ splitInToken,
405
+ tokenForGet,
406
+ type InListToken,
407
+ type NarrowedIn,
408
+ type QueryPart,
409
+ } from './query-batch'
395
410
  export {
396
411
  useOptionsResolver,
397
412
  projectOption,
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { visibleRelationInclude } from './list-include'
3
+ import type { ColumnDefinition } from './types'
4
+
5
+ function col(partial: Partial<ColumnDefinition> & { key: string }): ColumnDefinition {
6
+ return { label: partial.key, type: 'text', sortable: false, filterable: false, ...partial }
7
+ }
8
+
9
+ describe('visibleRelationInclude', () => {
10
+ it('omits the parameter when the table has no relations', () => {
11
+ expect(visibleRelationInclude([col({ key: 'name' })])).toBeUndefined()
12
+ expect(visibleRelationInclude(undefined)).toBeUndefined()
13
+ })
14
+
15
+ it('lists only the visible relation columns', () => {
16
+ const include = visibleRelationInclude([
17
+ col({ key: 'name' }),
18
+ col({ key: 'customer_id', type: 'relation' }),
19
+ col({ key: 'warehouse_id', ref: 'inventory.Warehouse', hidden: true }),
20
+ col({ key: 'created_by', type: 'creator' }),
21
+ col({ key: 'items', item_fields: [{ key: 'product_id', label: 'Producto' }] }),
22
+ ])
23
+ expect(include).toBe('customer_id,created_by,items')
24
+ })
25
+
26
+ it('drops a column the view hid', () => {
27
+ const include = visibleRelationInclude(
28
+ [col({ key: 'customer_id', type: 'relation' }), col({ key: 'user_id', type: 'user' })],
29
+ ['user_id'],
30
+ )
31
+ expect(include).toBe('customer_id')
32
+ })
33
+ })
@@ -0,0 +1,34 @@
1
+ import type { ColumnDefinition } from './types'
2
+
3
+ /**
4
+ * Columns whose labels come from a relation lookup. The list asks the server
5
+ * to resolve only these, so hidden foreign keys do not each become a query.
6
+ * Returns undefined when nothing relational is visible: the server then keeps
7
+ * resolving everything, which is what hosts do before they send `include=`.
8
+ */
9
+ export function visibleRelationInclude(
10
+ columns: ColumnDefinition[] | undefined,
11
+ hidden: string[] = [],
12
+ ): string | undefined {
13
+ if (!columns?.length) return undefined
14
+ const hide = new Set(hidden)
15
+ const keys: string[] = []
16
+ for (const col of columns) {
17
+ if (!col?.key || col.hidden || hide.has(col.key)) continue
18
+ if (col.visibility === 'modal' || col.visibility === 'list') continue
19
+ if (!columnNeedsRelation(col)) continue
20
+ keys.push(col.key)
21
+ }
22
+ if (keys.length === 0) return undefined
23
+ return keys.join(',')
24
+ }
25
+
26
+ function columnNeedsRelation(col: ColumnDefinition): boolean {
27
+ if (col.type === 'relation' || col.type === 'relation-badge-list' || col.type === 'creator' || col.type === 'user') {
28
+ return true
29
+ }
30
+ if (col.cellStyle === 'relation' || col.cellStyle === 'reference') return true
31
+ if (col.ref) return true
32
+ if (col.itemFields?.length || col.item_fields?.length) return true
33
+ return false
34
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ nameBatchTokens,
4
+ narrowInToken,
5
+ optionsBatchToken,
6
+ optionsModelFromUrl,
7
+ rememberInRows,
8
+ resetQueryBatchCache,
9
+ splitInToken,
10
+ tokenForGet,
11
+ } from './query-batch'
12
+
13
+ describe('query batch tokens', () => {
14
+ it('names distinct tokens and keeps duplicates as one part', () => {
15
+ const named = nameBatchTokens([
16
+ 'o:Customer?field=id',
17
+ 'o:Product?field=id',
18
+ 'o:Customer?field=id',
19
+ ])
20
+ expect(named).toEqual([
21
+ { name: 'q1', token: 'o:Customer?field=id', plan: 'q1=o:Customer?field=id' },
22
+ { name: 'q2', token: 'o:Product?field=id', plan: 'q2=o:Product?field=id' },
23
+ ])
24
+ })
25
+
26
+ it('builds an options token from the canonical url', () => {
27
+ expect(optionsModelFromUrl('/options/Customer')).toBe('Customer')
28
+ expect(optionsModelFromUrl('/data/Customer')).toBeNull()
29
+ expect(optionsBatchToken('Customer', 'id', 'lop', 20, undefined)).toBe(
30
+ 'o:Customer?field=id&q=lop&limit=20',
31
+ )
32
+ })
33
+
34
+ it('keeps each in: id and asks only for the ones that are missing', () => {
35
+ resetQueryBatchCache()
36
+ const token = 't:Stock?per_page=200&product_id=in:a,b,c&skip_refs=1'
37
+ expect(splitInToken(token)?.shape).toBe('t:Stock?per_page=200&product_id=in:$1&skip_refs=1')
38
+ expect(splitInToken('t:Stock?product_id=in:@p1.id')).toBeNull()
39
+
40
+ rememberInRows(token, ['a', 'b'], [{ product_id: 'a', quantity: 1 }])
41
+ const narrowed = narrowInToken(token)
42
+ expect(narrowed?.missingIds).toEqual(['c'])
43
+ expect(narrowed?.rows).toEqual([{ product_id: 'a', quantity: 1 }])
44
+ expect(narrowed?.wire).toContain('in%3Ac')
45
+ expect(narrowed?.wire).not.toContain('%2Cb')
46
+
47
+ rememberInRows(token, ['c'], [{ product_id: 'c', quantity: 4 }])
48
+ const done = narrowInToken('t:Stock?product_id=in:a,c&per_page=200&skip_refs=1')
49
+ expect(done?.wire).toBe('')
50
+ expect(done?.rows).toEqual([
51
+ { product_id: 'a', quantity: 1 },
52
+ { product_id: 'c', quantity: 4 },
53
+ ])
54
+ resetQueryBatchCache()
55
+ })
56
+
57
+ it('maps table reads onto batch tokens and leaves aggregates on GET', () => {
58
+ expect(tokenForGet('/metadata/table/Sale')).toBe('m:Sale')
59
+ expect(tokenForGet('/metadata/modal/Sale')).toBe('mm:Sale')
60
+ expect(tokenForGet('/data/Stock', { per_page: 20, page: 1, 'f_product_id': 'in:a,b' })).toBe(
61
+ 't:Stock?f_product_id=in%3Aa%2Cb&page=1&per_page=20',
62
+ )
63
+ expect(tokenForGet('/data/Stock/aggregate', { page: 1 })).toBeNull()
64
+ expect(tokenForGet('/data/Stock/me')).toBeNull()
65
+ expect(tokenForGet('/options/Customer?field=name&q=lop')).toBe('o:Customer?field=name&q=lop')
66
+ })
67
+ })
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Coalesces concurrent reads into one POST /api/q.
3
+ *
4
+ * Callers enqueue a batch token (`o:Customer?field=id&q=lop`). Tokens that
5
+ * arrive inside an 8ms window share one request. Each part keeps its own
6
+ * success, data and etag. A failed part does not reject the others.
7
+ */
8
+ import type { ApiClient } from './api-context'
9
+
10
+ export interface QueryPart {
11
+ success: boolean
12
+ data?: unknown
13
+ meta?: unknown
14
+ message?: string
15
+ etag?: string
16
+ not_modified?: boolean
17
+ }
18
+
19
+ const WINDOW_MS = 8
20
+ const PART_TTL_MS = 30_000
21
+ const ENTITY_TTL_MS = 30_000
22
+
23
+ type Waiter = {
24
+ token: string
25
+ wire: string
26
+ resolve: (part: QueryPart) => void
27
+ reject: (error: Error) => void
28
+ cachedRows?: Record<string, unknown>[]
29
+ missingIds?: string[]
30
+ field?: string
31
+ }
32
+
33
+ type CachedPart = { part: QueryPart; at: number }
34
+ type CachedEntity = { row: Record<string, unknown> | null; at: number }
35
+
36
+ const partCache = new Map<string, CachedPart>()
37
+ const entityCache = new Map<string, CachedEntity>()
38
+
39
+ export interface InListToken {
40
+ field: string
41
+ ids: string[]
42
+ shape: string
43
+ }
44
+
45
+ /** A concrete `t:` `in:` list. `@` references stay whole: the ids are not known yet. */
46
+ export function splitInToken(token: string): InListToken | null {
47
+ const q = token.indexOf('?')
48
+ if (q < 0 || !token.startsWith('t:')) return null
49
+ const model = token.slice(2, q)
50
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(model)) return null
51
+ const params = new URLSearchParams(token.slice(q + 1))
52
+ let field = ''
53
+ let ids: string[] = []
54
+ for (const [key, value] of params.entries()) {
55
+ if (!value.startsWith('in:')) continue
56
+ if (field || value.includes('@')) return null
57
+ field = key
58
+ ids = value
59
+ .slice(3)
60
+ .split(',')
61
+ .map((id) => id.trim())
62
+ .filter(Boolean)
63
+ }
64
+ if (!field || ids.length === 0) return null
65
+ const keys = [...new Set(params.keys())].sort()
66
+ const bits = keys.map((key) => `${key}=${key === field ? 'in:$1' : (params.get(key) ?? '')}`)
67
+ return { field, ids, shape: `t:${model}?${bits.join('&')}` }
68
+ }
69
+
70
+ function entityKey(scope: string, shape: string, id: string): string {
71
+ return `${scope}\n${shape}\n${id}`
72
+ }
73
+
74
+ function replaceInIds(token: string, field: string, ids: string[]): string {
75
+ const q = token.indexOf('?')
76
+ const params = new URLSearchParams(token.slice(q + 1))
77
+ params.set(field, `in:${ids.join(',')}`)
78
+ return `${token.slice(0, q)}?${params.toString()}`
79
+ }
80
+
81
+ export interface NarrowedIn {
82
+ wire: string
83
+ rows: Record<string, unknown>[]
84
+ missingIds: string[]
85
+ field: string
86
+ shape: string
87
+ }
88
+
89
+ /** Drops ids already cached for this shape. An empty wire means nothing has to travel. */
90
+ export function narrowInToken(token: string, now = Date.now()): NarrowedIn | null {
91
+ const parsed = splitInToken(token)
92
+ if (!parsed) return null
93
+ const scope = cacheScope()
94
+ const rows: Record<string, unknown>[] = []
95
+ const missingIds: string[] = []
96
+ for (const id of parsed.ids) {
97
+ const hit = entityCache.get(entityKey(scope, parsed.shape, id))
98
+ if (!hit || now - hit.at >= ENTITY_TTL_MS) {
99
+ missingIds.push(id)
100
+ continue
101
+ }
102
+ if (hit.row) rows.push(hit.row)
103
+ }
104
+ if (missingIds.length === parsed.ids.length) return null
105
+ return {
106
+ wire: missingIds.length === 0 ? '' : replaceInIds(token, parsed.field, missingIds),
107
+ rows,
108
+ missingIds,
109
+ field: parsed.field,
110
+ shape: parsed.shape,
111
+ }
112
+ }
113
+
114
+ /** Stores one row per requested id. An id with no row is remembered as empty. */
115
+ export function rememberInRows(
116
+ token: string,
117
+ ids: string[],
118
+ rows: unknown,
119
+ now = Date.now(),
120
+ ): void {
121
+ const parsed = splitInToken(token)
122
+ if (!parsed) return
123
+ const scope = cacheScope()
124
+ const seen = new Set<string>()
125
+ if (Array.isArray(rows)) {
126
+ for (const row of rows) {
127
+ if (!row || typeof row !== 'object') continue
128
+ const id = String((row as Record<string, unknown>)[parsed.field] ?? '').trim()
129
+ if (!id) continue
130
+ seen.add(id)
131
+ entityCache.set(entityKey(scope, parsed.shape, id), {
132
+ row: row as Record<string, unknown>,
133
+ at: now,
134
+ })
135
+ }
136
+ }
137
+ for (const id of ids) {
138
+ if (seen.has(id)) continue
139
+ entityCache.set(entityKey(scope, parsed.shape, id), { row: null, at: now })
140
+ }
141
+ }
142
+
143
+ export function resetQueryBatchCache(): void {
144
+ partCache.clear()
145
+ entityCache.clear()
146
+ waiters = []
147
+ if (timer != null) clearTimeout(timer)
148
+ timer = null
149
+ }
150
+ let waiters: Waiter[] = []
151
+ let timer: ReturnType<typeof setTimeout> | null = null
152
+ let transport: ApiClient | null = null
153
+
154
+ function mergeCachedRows(cached: Record<string, unknown>[] | undefined, part: QueryPart): QueryPart {
155
+ if (!cached?.length) return part
156
+ const fresh = Array.isArray(part.data) ? part.data : []
157
+ return { ...part, data: [...cached, ...fresh] }
158
+ }
159
+
160
+ function cacheScope(): string {
161
+ if (typeof localStorage === 'undefined') return ''
162
+ try {
163
+ const raw = localStorage.getItem('auth_user')
164
+ if (!raw) return ''
165
+ return String((JSON.parse(raw) as { organization_id?: string }).organization_id ?? '')
166
+ } catch {
167
+ return ''
168
+ }
169
+ }
170
+
171
+ export function optionsModelFromUrl(url: string): string | null {
172
+ const match = url.match(/^\/options\/([A-Za-z][A-Za-z0-9_]*)$/)
173
+ return match ? match[1] : null
174
+ }
175
+
176
+ export function optionsBatchToken(
177
+ model: string,
178
+ field: string,
179
+ query: string,
180
+ limit: number | undefined,
181
+ filter: string | undefined,
182
+ ): string {
183
+ const params = new URLSearchParams()
184
+ params.set('field', field)
185
+ if (query) params.set('q', query)
186
+ if (typeof limit === 'number' && limit > 0) params.set('limit', String(limit))
187
+ if (filter) params.set('filter_value', filter)
188
+ return `o:${model}?${params.toString()}`
189
+ }
190
+
191
+ /** Names each distinct token so the server can key the response. */
192
+ export function nameBatchTokens(tokens: string[]): { name: string; token: string; plan: string }[] {
193
+ const unique: string[] = []
194
+ const seen = new Set<string>()
195
+ for (const token of tokens) {
196
+ if (seen.has(token)) continue
197
+ seen.add(token)
198
+ unique.push(token)
199
+ }
200
+ return unique.map((token, index) => {
201
+ const name = `q${index + 1}`
202
+ return { name, token, plan: `${name}=${token}` }
203
+ })
204
+ }
205
+
206
+ export function loadQueryPart(api: ApiClient, token: string): Promise<QueryPart> {
207
+ const cached = partCache.get(`${cacheScope()}\n${token}`)
208
+ if (cached && Date.now() - cached.at < PART_TTL_MS && cached.part.success) {
209
+ return Promise.resolve(cached.part)
210
+ }
211
+ const narrowed = narrowInToken(token)
212
+ if (narrowed && narrowed.wire === '') {
213
+ return Promise.resolve({ success: true, data: narrowed.rows, meta: { total: narrowed.rows.length } })
214
+ }
215
+ return new Promise((resolve, reject) => {
216
+ transport = api
217
+ waiters.push({
218
+ token,
219
+ wire: narrowed?.wire ?? token,
220
+ resolve,
221
+ reject,
222
+ cachedRows: narrowed?.rows,
223
+ missingIds: narrowed?.missingIds,
224
+ field: narrowed?.field,
225
+ })
226
+ if (timer == null) timer = setTimeout(() => void flushQueryBatch(), WINDOW_MS)
227
+ })
228
+ }
229
+
230
+ async function flushQueryBatch() {
231
+ timer = null
232
+ const batch = waiters
233
+ waiters = []
234
+ const api = transport
235
+ if (!api || batch.length === 0) return
236
+ const named = nameBatchTokens(batch.map((waiter) => waiter.wire))
237
+ const byToken = new Map(named.map((part) => [part.token, part.name]))
238
+ const inm: Record<string, string> = {}
239
+ const scope = cacheScope()
240
+ for (const part of named) {
241
+ const cached = partCache.get(`${scope}\n${part.token}`)
242
+ if (cached?.part.etag) inm[part.name] = cached.part.etag
243
+ }
244
+ try {
245
+ const res = await api.post('/q', { parts: named.map((part) => part.plan), inm })
246
+ const body = res?.data
247
+ if (!body || body.success !== true || !body.parts) {
248
+ throw new Error('batch unavailable')
249
+ }
250
+ for (const waiter of batch) {
251
+ const name = byToken.get(waiter.wire)
252
+ const part = name ? (body.parts[name] as QueryPart | undefined) : undefined
253
+ if (!part) {
254
+ waiter.reject(new Error('batch part missing'))
255
+ continue
256
+ }
257
+ if (part.not_modified) {
258
+ const cached = partCache.get(`${scope}\n${waiter.token}`)
259
+ if (cached) {
260
+ waiter.resolve(cached.part)
261
+ continue
262
+ }
263
+ }
264
+ const merged = mergeCachedRows(waiter.cachedRows, part)
265
+ if (waiter.missingIds && waiter.field) {
266
+ rememberInRows(waiter.token, waiter.missingIds, part.data)
267
+ }
268
+ if (part.success) {
269
+ partCache.set(`${scope}\n${waiter.token}`, { part: merged, at: Date.now() })
270
+ }
271
+ waiter.resolve(merged)
272
+ }
273
+ } catch (err) {
274
+ const error = err instanceof Error ? err : new Error(String(err))
275
+ for (const waiter of batch) waiter.reject(error)
276
+ }
277
+ }
278
+
279
+ function canonicalParams(params?: Record<string, unknown>): string {
280
+ if (!params) return ''
281
+ const keys = Object.keys(params)
282
+ .filter((key) => params[key] != null && params[key] !== '')
283
+ .sort()
284
+ const search = new URLSearchParams()
285
+ for (const key of keys) search.set(key, String(params[key]))
286
+ return search.toString()
287
+ }
288
+
289
+ /**
290
+ * Token for a host GET that the batch can serve. Custom endpoints, aggregates
291
+ * and `/me` stay on the legacy GET.
292
+ */
293
+ export function tokenForGet(url: string, params?: Record<string, unknown>): string | null {
294
+ const [path, query = ''] = url.split('?')
295
+ const table = path.match(/^\/metadata\/table\/([A-Za-z][A-Za-z0-9_]*)$/)
296
+ if (table) return `m:${table[1]}`
297
+ const modal = path.match(/^\/metadata\/modal\/([A-Za-z][A-Za-z0-9_]*)$/)
298
+ if (modal) return `mm:${modal[1]}`
299
+ const list = path.match(/^\/data\/([A-Za-z][A-Za-z0-9_]*)$/)
300
+ if (list) {
301
+ const q = canonicalParams(params)
302
+ return q ? `t:${list[1]}?${q}` : `t:${list[1]}`
303
+ }
304
+ const opt = path.match(/^\/options\/([A-Za-z][A-Za-z0-9_]*)$/)
305
+ if (!opt) return null
306
+ const search = new URLSearchParams(query)
307
+ if (params) {
308
+ for (const [key, value] of Object.entries(params)) {
309
+ if (value != null && value !== '') search.set(key, String(value))
310
+ }
311
+ }
312
+ const limit = Number(search.get('limit'))
313
+ return optionsBatchToken(
314
+ opt[1],
315
+ search.get('field') || 'id',
316
+ search.get('q') || search.get('search') || '',
317
+ Number.isFinite(limit) && limit > 0 ? limit : undefined,
318
+ search.get('filter_value') || undefined,
319
+ )
320
+ }
321
+
322
+ function batchForbidden(message?: string): boolean {
323
+ return /forbidden|permiso/i.test(message || '')
324
+ }
325
+
326
+ /**
327
+ * GET through the batch when the URL is a list, table metadata, modal metadata
328
+ * or options read. Anything the batch refuses falls back to the original GET.
329
+ */
330
+ export async function batchGet(api: ApiClient, url: string, config?: { params?: Record<string, unknown> }) {
331
+ const token = tokenForGet(url, config?.params)
332
+ if (!token) return api.get(url, config)
333
+ try {
334
+ const part = await loadQueryPart(api, token)
335
+ if (!part.success) {
336
+ if (batchForbidden(part.message)) {
337
+ const err = new Error(part.message || 'forbidden') as Error & {
338
+ status: number
339
+ response: { status: number }
340
+ }
341
+ err.status = 403
342
+ err.response = { status: 403 }
343
+ throw err
344
+ }
345
+ return api.get(url, config)
346
+ }
347
+ return { data: { success: true, data: part.data, meta: part.meta } }
348
+ } catch (err) {
349
+ const status = (err as { status?: number; response?: { status?: number } })?.status
350
+ ?? (err as { response?: { status?: number } })?.response?.status
351
+ if (status === 403) throw err
352
+ return api.get(url, config)
353
+ }
354
+ }
@@ -12,6 +12,7 @@
12
12
  // free; legacy callers that still ship `searchEndpoint` keep working.
13
13
  import { useEffect, useMemo, useRef, useState } from 'react'
14
14
  import { useApi } from './api-context'
15
+ import { loadQueryPart, optionsBatchToken, optionsModelFromUrl } from './query-batch'
15
16
 
16
17
  export interface ResolvedOption {
17
18
  /** Canonical id (server-side primary key). */
@@ -237,7 +238,33 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
237
238
  if (query) params.q = query
238
239
  if (typeof limit === 'number' && limit > 0) params.limit = limit
239
240
  if (filterValue) params.filter_value = filterValue
240
- pending = api.get(url, { params }).then((res) => {
241
+ const model = optionsModelFromUrl(url)
242
+ pending = (async () => {
243
+ if (model) {
244
+ try {
245
+ const part = await loadQueryPart(
246
+ api,
247
+ optionsBatchToken(model, effectiveField, query ?? '', limit, filterValue),
248
+ )
249
+ if (!part.success) {
250
+ throw new Error(part.message || 'options resolver: unsuccessful response')
251
+ }
252
+ const payload = readOptionsEnvelope({
253
+ success: true,
254
+ data: part.data,
255
+ meta: part.meta,
256
+ })
257
+ if ((optionsEpoch.get(key) ?? 0) === epoch) {
258
+ optionsCache.set(key, { payload, at: Date.now() })
259
+ }
260
+ return payload
261
+ } catch (err) {
262
+ const msg = err instanceof Error ? err.message : ''
263
+ const batchMiss = msg === 'batch unavailable' || msg === 'batch part missing'
264
+ if (!batchMiss) throw err
265
+ }
266
+ }
267
+ const res = await api.get(url, { params })
241
268
  const body = (res as { data: any }).data
242
269
  if (!body || body.success !== true) {
243
270
  throw new Error(body?.message || 'options resolver: unsuccessful response')
@@ -247,7 +274,7 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
247
274
  optionsCache.set(key, { payload, at: Date.now() })
248
275
  }
249
276
  return payload
250
- }).finally(() => {
277
+ })().finally(() => {
251
278
  if (optionsInflight.get(key) === pending) optionsInflight.delete(key)
252
279
  })
253
280
  optionsInflight.set(key, pending)
package/vitest.config.ts CHANGED
@@ -4,5 +4,6 @@ export default defineConfig({
4
4
  test: {
5
5
  environment: 'node',
6
6
  include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
7
+ setupFiles: ['./vitest.setup.ts'],
7
8
  },
8
9
  })