@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/CHANGELOG.md +12 -0
- package/dist/api-context.d.ts.map +1 -1
- package/dist/api-context.js +10 -2
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +8 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/list-include.d.ts +9 -0
- package/dist/list-include.d.ts.map +1 -0
- package/dist/list-include.js +36 -0
- package/dist/query-batch.d.ts +60 -0
- package/dist/query-batch.d.ts.map +1 -0
- package/dist/query-batch.js +306 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +28 -2
- package/package.json +1 -1
- package/src/api-context.tsx +10 -2
- package/src/dynamic-table.tsx +10 -2
- package/src/index.ts +15 -0
- package/src/list-include.test.ts +33 -0
- package/src/list-include.ts +34 -0
- package/src/query-batch.test.ts +67 -0
- package/src/query-batch.ts +354 -0
- package/src/use-options-resolver.ts +29 -2
- package/vitest.config.ts +1 -0
- package/vitest.setup.ts +29 -0
package/package.json
CHANGED
package/src/api-context.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -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
|
-
|
|
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)
|