@asteby/metacore-runtime-react 39.2.7 → 39.2.9
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 +15 -0
- package/dist/api-context.d.ts.map +1 -1
- package/dist/api-context.js +50 -2
- package/dist/calendar-date.d.ts +4 -0
- package/dist/calendar-date.d.ts.map +1 -0
- package/dist/calendar-date.js +18 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +6 -1
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +3 -1
- package/dist/dynamic-date-field.d.ts.map +1 -1
- package/dist/dynamic-date-field.js +2 -12
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +8 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- 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/permissions-manager.d.ts +9 -0
- package/dist/permissions-manager.d.ts.map +1 -1
- package/dist/permissions-manager.js +40 -14
- package/dist/query-batch.d.ts +48 -0
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +251 -8
- package/dist/use-options-resolver.d.ts +6 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +16 -2
- package/dist/validation-catalog.d.ts.map +1 -1
- package/dist/validation-catalog.js +2 -0
- package/package.json +3 -3
- package/src/__tests__/calendar-date.test.ts +22 -0
- package/src/__tests__/options-cache-invalidation.test.tsx +126 -0
- package/src/__tests__/permissions-manager.test.tsx +73 -0
- package/src/api-context.tsx +64 -2
- package/src/calendar-date.ts +16 -0
- package/src/dialogs/dynamic-record.tsx +6 -1
- package/src/dynamic-columns.tsx +3 -1
- package/src/dynamic-date-field.tsx +2 -10
- package/src/dynamic-table.tsx +10 -2
- package/src/index.ts +10 -0
- package/src/list-include.test.ts +33 -0
- package/src/list-include.ts +34 -0
- package/src/permissions-manager.tsx +58 -19
- package/src/query-batch.test.ts +122 -2
- package/src/query-batch.ts +274 -7
- package/src/use-options-resolver.ts +17 -2
- package/src/validation-catalog.ts +2 -0
- package/vitest.config.ts +1 -0
- package/vitest.setup.ts +29 -0
package/src/query-batch.test.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
invalidateQueryBatchData,
|
|
4
|
+
loadQueryPart,
|
|
5
|
+
nameBatchTokens,
|
|
6
|
+
narrowInToken,
|
|
7
|
+
optionsBatchToken,
|
|
8
|
+
optionsModelFromUrl,
|
|
9
|
+
rememberInRows,
|
|
10
|
+
resetQueryBatchCache,
|
|
11
|
+
splitInToken,
|
|
12
|
+
tokenForGet,
|
|
13
|
+
} from './query-batch'
|
|
3
14
|
|
|
4
15
|
describe('query batch tokens', () => {
|
|
5
16
|
it('names distinct tokens and keeps duplicates as one part', () => {
|
|
@@ -21,4 +32,113 @@ describe('query batch tokens', () => {
|
|
|
21
32
|
'o:Customer?field=id&q=lop&limit=20',
|
|
22
33
|
)
|
|
23
34
|
})
|
|
35
|
+
|
|
36
|
+
it('keeps each in: id and asks only for the ones that are missing', () => {
|
|
37
|
+
resetQueryBatchCache()
|
|
38
|
+
const token = 't:Stock?per_page=200&product_id=in:a,b,c&skip_refs=1'
|
|
39
|
+
expect(splitInToken(token)?.shape).toBe('t:Stock?per_page=200&product_id=in:$1&skip_refs=1')
|
|
40
|
+
expect(splitInToken('t:Stock?product_id=in:@p1.id')).toBeNull()
|
|
41
|
+
|
|
42
|
+
rememberInRows(token, ['a', 'b'], [{ product_id: 'a', quantity: 1 }])
|
|
43
|
+
const narrowed = narrowInToken(token)
|
|
44
|
+
expect(narrowed?.missingIds).toEqual(['c'])
|
|
45
|
+
expect(narrowed?.rows).toEqual([{ product_id: 'a', quantity: 1 }])
|
|
46
|
+
expect(narrowed?.wire).toContain('in%3Ac')
|
|
47
|
+
expect(narrowed?.wire).not.toContain('%2Cb')
|
|
48
|
+
|
|
49
|
+
rememberInRows(token, ['c'], [{ product_id: 'c', quantity: 4 }])
|
|
50
|
+
const done = narrowInToken('t:Stock?product_id=in:a,c&per_page=200&skip_refs=1')
|
|
51
|
+
expect(done?.wire).toBe('')
|
|
52
|
+
expect(done?.rows).toEqual([
|
|
53
|
+
{ product_id: 'a', quantity: 1 },
|
|
54
|
+
{ product_id: 'c', quantity: 4 },
|
|
55
|
+
])
|
|
56
|
+
resetQueryBatchCache()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('maps table reads onto batch tokens and leaves aggregates on GET', () => {
|
|
60
|
+
expect(tokenForGet('/metadata/table/Sale')).toBe('m:Sale')
|
|
61
|
+
expect(tokenForGet('/metadata/modal/Sale')).toBe('mm:Sale')
|
|
62
|
+
expect(tokenForGet('/data/Stock', { per_page: 20, page: 1, 'f_product_id': 'in:a,b' })).toBe(
|
|
63
|
+
't:Stock?f_product_id=in%3Aa%2Cb&page=1&per_page=20',
|
|
64
|
+
)
|
|
65
|
+
expect(tokenForGet('/data/Stock/aggregate', { page: 1 })).toBeNull()
|
|
66
|
+
expect(tokenForGet('/data/Stock/me')).toBeNull()
|
|
67
|
+
expect(tokenForGet('/options/Customer?field=name&q=lop')).toBe('o:Customer?field=name&q=lop')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('keeps every row of an id and reads f_ filters by their column', () => {
|
|
71
|
+
resetQueryBatchCache()
|
|
72
|
+
const token = 't:Stock?f_product_id=in:a,b&per_page=200'
|
|
73
|
+
rememberInRows(token, ['a', 'b'], [
|
|
74
|
+
{ product_id: 'a', warehouse_id: 'w1' },
|
|
75
|
+
{ product_id: 'a', warehouse_id: 'w2' },
|
|
76
|
+
{ product_id: 'b', warehouse_id: 'w1' },
|
|
77
|
+
])
|
|
78
|
+
const done = narrowInToken(token)
|
|
79
|
+
expect(done?.wire).toBe('')
|
|
80
|
+
expect(done?.rows).toHaveLength(3)
|
|
81
|
+
resetQueryBatchCache()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('does not split a paginated list', () => {
|
|
85
|
+
expect(splitInToken('t:Stock?f_product_id=in:a,b&page=1&per_page=25')).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('query batch freshness', () => {
|
|
90
|
+
function fakeApi(responses: unknown[]) {
|
|
91
|
+
const post = vi.fn(async (_url: string, body: { parts: string[]; inm: Record<string, string> }) => {
|
|
92
|
+
const next = responses.shift() as (b: typeof body) => unknown
|
|
93
|
+
return { data: next(body) }
|
|
94
|
+
})
|
|
95
|
+
return { post, get: vi.fn(), put: vi.fn(), delete: vi.fn() }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
it('reads a list again instead of serving it from memory', async () => {
|
|
99
|
+
resetQueryBatchCache()
|
|
100
|
+
const api = fakeApi([
|
|
101
|
+
() => ({ success: true, parts: { q1: { success: true, data: [], etag: '"q-1-2"' } } }),
|
|
102
|
+
(body: { inm: Record<string, string> }) => {
|
|
103
|
+
// A length-based etag is not trusted: the stale copy is not offered.
|
|
104
|
+
expect(body.inm).toEqual({})
|
|
105
|
+
return { success: true, parts: { q1: { success: true, data: [{ id: 'x' }], etag: '"q-1-2"' } } }
|
|
106
|
+
},
|
|
107
|
+
])
|
|
108
|
+
const token = 't:QuoteItem?f_quote_id=eq%3Aq1&page=1&per_page=25'
|
|
109
|
+
expect((await loadQueryPart(api as never, token)).data).toEqual([])
|
|
110
|
+
expect((await loadQueryPart(api as never, token)).data).toEqual([{ id: 'x' }])
|
|
111
|
+
expect(api.post).toHaveBeenCalledTimes(2)
|
|
112
|
+
resetQueryBatchCache()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('reuses a list only when the content etag says it did not change', async () => {
|
|
116
|
+
resetQueryBatchCache()
|
|
117
|
+
const api = fakeApi([
|
|
118
|
+
() => ({ success: true, parts: { q1: { success: true, data: [{ id: 'x' }], etag: '"qh-abc"' } } }),
|
|
119
|
+
(body: { inm: Record<string, string> }) => {
|
|
120
|
+
expect(body.inm).toEqual({ q1: '"qh-abc"' })
|
|
121
|
+
return { success: true, parts: { q1: { success: true, not_modified: true, etag: '"qh-abc"' } } }
|
|
122
|
+
},
|
|
123
|
+
])
|
|
124
|
+
const token = 't:QuoteItem?page=1'
|
|
125
|
+
await loadQueryPart(api as never, token)
|
|
126
|
+
expect((await loadQueryPart(api as never, token)).data).toEqual([{ id: 'x' }])
|
|
127
|
+
resetQueryBatchCache()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('forgets remembered rows after a mutation but keeps metadata', async () => {
|
|
131
|
+
resetQueryBatchCache()
|
|
132
|
+
const api = fakeApi([
|
|
133
|
+
() => ({ success: true, parts: { q1: { success: true, data: { columns: [] } } } }),
|
|
134
|
+
])
|
|
135
|
+
await loadQueryPart(api as never, 'm:Quote')
|
|
136
|
+
rememberInRows('t:Stock?product_id=in:a', ['a'], [{ product_id: 'a' }])
|
|
137
|
+
invalidateQueryBatchData()
|
|
138
|
+
expect(narrowInToken('t:Stock?product_id=in:a')).toBeNull()
|
|
139
|
+
// Metadata is still served from memory: no second request.
|
|
140
|
+
await loadQueryPart(api as never, 'm:Quote')
|
|
141
|
+
expect(api.post).toHaveBeenCalledTimes(1)
|
|
142
|
+
resetQueryBatchCache()
|
|
143
|
+
})
|
|
24
144
|
})
|
package/src/query-batch.ts
CHANGED
|
@@ -18,20 +18,188 @@ export interface QueryPart {
|
|
|
18
18
|
|
|
19
19
|
const WINDOW_MS = 8
|
|
20
20
|
const PART_TTL_MS = 30_000
|
|
21
|
+
const ENTITY_TTL_MS = 30_000
|
|
21
22
|
|
|
22
23
|
type Waiter = {
|
|
23
24
|
token: string
|
|
25
|
+
wire: string
|
|
24
26
|
resolve: (part: QueryPart) => void
|
|
25
27
|
reject: (error: Error) => void
|
|
28
|
+
cachedRows?: Record<string, unknown>[]
|
|
29
|
+
missingIds?: string[]
|
|
30
|
+
field?: string
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
type CachedPart = { part: QueryPart; at: number }
|
|
34
|
+
type CachedEntity = { rows: Record<string, unknown>[]; at: number }
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Only metadata and i18n are served from memory without asking the server.
|
|
38
|
+
* Rows (`t:` lists, `r:` records) and options change with every save: they
|
|
39
|
+
* always travel, and an unchanged payload comes back as `not_modified` when
|
|
40
|
+
* the server's etag hashes the content.
|
|
41
|
+
*/
|
|
42
|
+
function servedFromMemory(token: string): boolean {
|
|
43
|
+
return token.startsWith('m:') || token.startsWith('mm:') || token.startsWith('i:')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Etags the server derives from the payload itself (`"qh-…"`). The older
|
|
48
|
+
* `"q-<seed>-<len>"` ones only counted rows or bytes, so a value edited to one
|
|
49
|
+
* of the same length came back `not_modified` with the old data.
|
|
50
|
+
*/
|
|
51
|
+
function contentEtag(etag: string | undefined): boolean {
|
|
52
|
+
return !!etag && etag.startsWith('"qh-')
|
|
53
|
+
}
|
|
29
54
|
|
|
30
55
|
const partCache = new Map<string, CachedPart>()
|
|
56
|
+
const entityCache = new Map<string, CachedEntity>()
|
|
57
|
+
|
|
58
|
+
export interface InListToken {
|
|
59
|
+
field: string
|
|
60
|
+
ids: string[]
|
|
61
|
+
shape: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A concrete `t:` `in:` list. `@` references stay whole: the ids are not known
|
|
66
|
+
* yet. A paginated list (`page=`) is not split: serving part of it from memory
|
|
67
|
+
* would change which rows land on the page and the total.
|
|
68
|
+
*/
|
|
69
|
+
export function splitInToken(token: string): InListToken | null {
|
|
70
|
+
const q = token.indexOf('?')
|
|
71
|
+
if (q < 0 || !token.startsWith('t:')) return null
|
|
72
|
+
const model = token.slice(2, q)
|
|
73
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(model)) return null
|
|
74
|
+
const params = new URLSearchParams(token.slice(q + 1))
|
|
75
|
+
if (params.has('page')) return null
|
|
76
|
+
let field = ''
|
|
77
|
+
let ids: string[] = []
|
|
78
|
+
for (const [key, value] of params.entries()) {
|
|
79
|
+
if (!value.startsWith('in:')) continue
|
|
80
|
+
if (field || value.includes('@')) return null
|
|
81
|
+
field = key
|
|
82
|
+
ids = value
|
|
83
|
+
.slice(3)
|
|
84
|
+
.split(',')
|
|
85
|
+
.map((id) => id.trim())
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
}
|
|
88
|
+
if (!field || ids.length === 0) return null
|
|
89
|
+
const keys = [...new Set(params.keys())].sort()
|
|
90
|
+
const bits = keys.map((key) => `${key}=${key === field ? 'in:$1' : (params.get(key) ?? '')}`)
|
|
91
|
+
return { field, ids, shape: `t:${model}?${bits.join('&')}` }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function entityKey(scope: string, shape: string, id: string): string {
|
|
95
|
+
return `${scope}\n${shape}\n${id}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The row column an `in:` filter matches: `f_product_id` filters `product_id`. */
|
|
99
|
+
function rowField(param: string): string {
|
|
100
|
+
return param.startsWith('f_') ? param.slice(2) : param
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function replaceInIds(token: string, field: string, ids: string[]): string {
|
|
104
|
+
const q = token.indexOf('?')
|
|
105
|
+
const params = new URLSearchParams(token.slice(q + 1))
|
|
106
|
+
params.set(field, `in:${ids.join(',')}`)
|
|
107
|
+
return `${token.slice(0, q)}?${params.toString()}`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface NarrowedIn {
|
|
111
|
+
wire: string
|
|
112
|
+
rows: Record<string, unknown>[]
|
|
113
|
+
missingIds: string[]
|
|
114
|
+
field: string
|
|
115
|
+
shape: string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Drops ids already cached for this shape. An empty wire means nothing has to travel. */
|
|
119
|
+
export function narrowInToken(token: string, now = Date.now()): NarrowedIn | null {
|
|
120
|
+
const parsed = splitInToken(token)
|
|
121
|
+
if (!parsed) return null
|
|
122
|
+
const scope = cacheScope()
|
|
123
|
+
const rows: Record<string, unknown>[] = []
|
|
124
|
+
const missingIds: string[] = []
|
|
125
|
+
for (const id of parsed.ids) {
|
|
126
|
+
const hit = entityCache.get(entityKey(scope, parsed.shape, id))
|
|
127
|
+
if (!hit || now - hit.at >= ENTITY_TTL_MS) {
|
|
128
|
+
missingIds.push(id)
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
rows.push(...hit.rows)
|
|
132
|
+
}
|
|
133
|
+
if (missingIds.length === parsed.ids.length) return null
|
|
134
|
+
return {
|
|
135
|
+
wire: missingIds.length === 0 ? '' : replaceInIds(token, parsed.field, missingIds),
|
|
136
|
+
rows,
|
|
137
|
+
missingIds,
|
|
138
|
+
field: parsed.field,
|
|
139
|
+
shape: parsed.shape,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Stores every row of each requested id (one id can match many rows: the
|
|
145
|
+
* stock of a product in several warehouses). An id with no row is remembered
|
|
146
|
+
* as empty.
|
|
147
|
+
*/
|
|
148
|
+
export function rememberInRows(
|
|
149
|
+
token: string,
|
|
150
|
+
ids: string[],
|
|
151
|
+
rows: unknown,
|
|
152
|
+
now = Date.now(),
|
|
153
|
+
): void {
|
|
154
|
+
const parsed = splitInToken(token)
|
|
155
|
+
if (!parsed) return
|
|
156
|
+
const scope = cacheScope()
|
|
157
|
+
const field = rowField(parsed.field)
|
|
158
|
+
const byId = new Map<string, Record<string, unknown>[]>()
|
|
159
|
+
for (const id of ids) byId.set(id, [])
|
|
160
|
+
if (Array.isArray(rows)) {
|
|
161
|
+
for (const row of rows) {
|
|
162
|
+
if (!row || typeof row !== 'object') continue
|
|
163
|
+
const id = String((row as Record<string, unknown>)[field] ?? '').trim()
|
|
164
|
+
const bucket = byId.get(id)
|
|
165
|
+
if (bucket) bucket.push(row as Record<string, unknown>)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const [id, bucket] of byId) {
|
|
169
|
+
entityCache.set(entityKey(scope, parsed.shape, id), { rows: bucket, at: now })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function resetQueryBatchCache(): void {
|
|
174
|
+
partCache.clear()
|
|
175
|
+
entityCache.clear()
|
|
176
|
+
waiters = []
|
|
177
|
+
if (timer != null) clearTimeout(timer)
|
|
178
|
+
timer = null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Forgets every row, record and option read. useApi calls it after each
|
|
183
|
+
* mutation so the list that just gained, changed or lost a row reads it
|
|
184
|
+
* again instead of the copy from before the save. Metadata and i18n stay.
|
|
185
|
+
*/
|
|
186
|
+
export function invalidateQueryBatchData(): void {
|
|
187
|
+
for (const key of [...partCache.keys()]) {
|
|
188
|
+
const token = key.slice(key.indexOf('\n') + 1)
|
|
189
|
+
if (!servedFromMemory(token)) partCache.delete(key)
|
|
190
|
+
}
|
|
191
|
+
entityCache.clear()
|
|
192
|
+
}
|
|
31
193
|
let waiters: Waiter[] = []
|
|
32
194
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
33
195
|
let transport: ApiClient | null = null
|
|
34
196
|
|
|
197
|
+
function mergeCachedRows(cached: Record<string, unknown>[] | undefined, part: QueryPart): QueryPart {
|
|
198
|
+
if (!cached?.length) return part
|
|
199
|
+
const fresh = Array.isArray(part.data) ? part.data : []
|
|
200
|
+
return { ...part, data: [...cached, ...fresh] }
|
|
201
|
+
}
|
|
202
|
+
|
|
35
203
|
function cacheScope(): string {
|
|
36
204
|
if (typeof localStorage === 'undefined') return ''
|
|
37
205
|
try {
|
|
@@ -80,12 +248,24 @@ export function nameBatchTokens(tokens: string[]): { name: string; token: string
|
|
|
80
248
|
|
|
81
249
|
export function loadQueryPart(api: ApiClient, token: string): Promise<QueryPart> {
|
|
82
250
|
const cached = partCache.get(`${cacheScope()}\n${token}`)
|
|
83
|
-
if (cached && Date.now() - cached.at < PART_TTL_MS && cached.part.success) {
|
|
251
|
+
if (cached && servedFromMemory(token) && Date.now() - cached.at < PART_TTL_MS && cached.part.success) {
|
|
84
252
|
return Promise.resolve(cached.part)
|
|
85
253
|
}
|
|
254
|
+
const narrowed = narrowInToken(token)
|
|
255
|
+
if (narrowed && narrowed.wire === '') {
|
|
256
|
+
return Promise.resolve({ success: true, data: narrowed.rows, meta: { total: narrowed.rows.length } })
|
|
257
|
+
}
|
|
86
258
|
return new Promise((resolve, reject) => {
|
|
87
259
|
transport = api
|
|
88
|
-
waiters.push({
|
|
260
|
+
waiters.push({
|
|
261
|
+
token,
|
|
262
|
+
wire: narrowed?.wire ?? token,
|
|
263
|
+
resolve,
|
|
264
|
+
reject,
|
|
265
|
+
cachedRows: narrowed?.rows,
|
|
266
|
+
missingIds: narrowed?.missingIds,
|
|
267
|
+
field: narrowed?.field,
|
|
268
|
+
})
|
|
89
269
|
if (timer == null) timer = setTimeout(() => void flushQueryBatch(), WINDOW_MS)
|
|
90
270
|
})
|
|
91
271
|
}
|
|
@@ -96,13 +276,14 @@ async function flushQueryBatch() {
|
|
|
96
276
|
waiters = []
|
|
97
277
|
const api = transport
|
|
98
278
|
if (!api || batch.length === 0) return
|
|
99
|
-
const named = nameBatchTokens(batch.map((waiter) => waiter.
|
|
279
|
+
const named = nameBatchTokens(batch.map((waiter) => waiter.wire))
|
|
100
280
|
const byToken = new Map(named.map((part) => [part.token, part.name]))
|
|
101
281
|
const inm: Record<string, string> = {}
|
|
102
282
|
const scope = cacheScope()
|
|
103
283
|
for (const part of named) {
|
|
104
284
|
const cached = partCache.get(`${scope}\n${part.token}`)
|
|
105
|
-
|
|
285
|
+
const etag = cached?.part.etag
|
|
286
|
+
if (etag && (servedFromMemory(part.token) || contentEtag(etag))) inm[part.name] = etag
|
|
106
287
|
}
|
|
107
288
|
try {
|
|
108
289
|
const res = await api.post('/q', { parts: named.map((part) => part.plan), inm })
|
|
@@ -111,7 +292,7 @@ async function flushQueryBatch() {
|
|
|
111
292
|
throw new Error('batch unavailable')
|
|
112
293
|
}
|
|
113
294
|
for (const waiter of batch) {
|
|
114
|
-
const name = byToken.get(waiter.
|
|
295
|
+
const name = byToken.get(waiter.wire)
|
|
115
296
|
const part = name ? (body.parts[name] as QueryPart | undefined) : undefined
|
|
116
297
|
if (!part) {
|
|
117
298
|
waiter.reject(new Error('batch part missing'))
|
|
@@ -120,17 +301,103 @@ async function flushQueryBatch() {
|
|
|
120
301
|
if (part.not_modified) {
|
|
121
302
|
const cached = partCache.get(`${scope}\n${waiter.token}`)
|
|
122
303
|
if (cached) {
|
|
304
|
+
cached.at = Date.now()
|
|
123
305
|
waiter.resolve(cached.part)
|
|
124
306
|
continue
|
|
125
307
|
}
|
|
308
|
+
// Invalidated while the request was in flight: nothing to
|
|
309
|
+
// reuse, so batchGet falls back to the plain GET.
|
|
310
|
+
waiter.reject(new Error('batch part not modified but not cached'))
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
const merged = mergeCachedRows(waiter.cachedRows, part)
|
|
314
|
+
if (waiter.missingIds && waiter.field) {
|
|
315
|
+
rememberInRows(waiter.token, waiter.missingIds, part.data)
|
|
126
316
|
}
|
|
127
317
|
if (part.success) {
|
|
128
|
-
partCache.set(`${scope}\n${waiter.token}`, { part, at: Date.now() })
|
|
318
|
+
partCache.set(`${scope}\n${waiter.token}`, { part: merged, at: Date.now() })
|
|
129
319
|
}
|
|
130
|
-
waiter.resolve(
|
|
320
|
+
waiter.resolve(merged)
|
|
131
321
|
}
|
|
132
322
|
} catch (err) {
|
|
133
323
|
const error = err instanceof Error ? err : new Error(String(err))
|
|
134
324
|
for (const waiter of batch) waiter.reject(error)
|
|
135
325
|
}
|
|
136
326
|
}
|
|
327
|
+
|
|
328
|
+
function canonicalParams(params?: Record<string, unknown>): string {
|
|
329
|
+
if (!params) return ''
|
|
330
|
+
const keys = Object.keys(params)
|
|
331
|
+
.filter((key) => params[key] != null && params[key] !== '')
|
|
332
|
+
.sort()
|
|
333
|
+
const search = new URLSearchParams()
|
|
334
|
+
for (const key of keys) search.set(key, String(params[key]))
|
|
335
|
+
return search.toString()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Token for a host GET that the batch can serve. Custom endpoints, aggregates
|
|
340
|
+
* and `/me` stay on the legacy GET.
|
|
341
|
+
*/
|
|
342
|
+
export function tokenForGet(url: string, params?: Record<string, unknown>): string | null {
|
|
343
|
+
const [path, query = ''] = url.split('?')
|
|
344
|
+
const table = path.match(/^\/metadata\/table\/([A-Za-z][A-Za-z0-9_]*)$/)
|
|
345
|
+
if (table) return `m:${table[1]}`
|
|
346
|
+
const modal = path.match(/^\/metadata\/modal\/([A-Za-z][A-Za-z0-9_]*)$/)
|
|
347
|
+
if (modal) return `mm:${modal[1]}`
|
|
348
|
+
const list = path.match(/^\/data\/([A-Za-z][A-Za-z0-9_]*)$/)
|
|
349
|
+
if (list) {
|
|
350
|
+
const q = canonicalParams(params)
|
|
351
|
+
return q ? `t:${list[1]}?${q}` : `t:${list[1]}`
|
|
352
|
+
}
|
|
353
|
+
const opt = path.match(/^\/options\/([A-Za-z][A-Za-z0-9_]*)$/)
|
|
354
|
+
if (!opt) return null
|
|
355
|
+
const search = new URLSearchParams(query)
|
|
356
|
+
if (params) {
|
|
357
|
+
for (const [key, value] of Object.entries(params)) {
|
|
358
|
+
if (value != null && value !== '') search.set(key, String(value))
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const limit = Number(search.get('limit'))
|
|
362
|
+
return optionsBatchToken(
|
|
363
|
+
opt[1],
|
|
364
|
+
search.get('field') || 'id',
|
|
365
|
+
search.get('q') || search.get('search') || '',
|
|
366
|
+
Number.isFinite(limit) && limit > 0 ? limit : undefined,
|
|
367
|
+
search.get('filter_value') || undefined,
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function batchForbidden(message?: string): boolean {
|
|
372
|
+
return /forbidden|permiso/i.test(message || '')
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* GET through the batch when the URL is a list, table metadata, modal metadata
|
|
377
|
+
* or options read. Anything the batch refuses falls back to the original GET.
|
|
378
|
+
*/
|
|
379
|
+
export async function batchGet(api: ApiClient, url: string, config?: { params?: Record<string, unknown> }) {
|
|
380
|
+
const token = tokenForGet(url, config?.params)
|
|
381
|
+
if (!token) return api.get(url, config)
|
|
382
|
+
try {
|
|
383
|
+
const part = await loadQueryPart(api, token)
|
|
384
|
+
if (!part.success) {
|
|
385
|
+
if (batchForbidden(part.message)) {
|
|
386
|
+
const err = new Error(part.message || 'forbidden') as Error & {
|
|
387
|
+
status: number
|
|
388
|
+
response: { status: number }
|
|
389
|
+
}
|
|
390
|
+
err.status = 403
|
|
391
|
+
err.response = { status: 403 }
|
|
392
|
+
throw err
|
|
393
|
+
}
|
|
394
|
+
return api.get(url, config)
|
|
395
|
+
}
|
|
396
|
+
return { data: { success: true, data: part.data, meta: part.meta } }
|
|
397
|
+
} catch (err) {
|
|
398
|
+
const status = (err as { status?: number; response?: { status?: number } })?.status
|
|
399
|
+
?? (err as { response?: { status?: number } })?.response?.status
|
|
400
|
+
if (status === 403) throw err
|
|
401
|
+
return api.get(url, config)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
@@ -113,6 +113,20 @@ type OptionsPayload = { options: ResolvedOption[]; meta: OptionsMeta }
|
|
|
113
113
|
const optionsCache = new Map<string, { payload: OptionsPayload; at: number }>()
|
|
114
114
|
const optionsInflight = new Map<string, Promise<OptionsPayload>>()
|
|
115
115
|
const optionsEpoch = new Map<string, number>()
|
|
116
|
+
// Bumped by invalidateOptionsCache: a lookup that started before a write must
|
|
117
|
+
// not store what it read.
|
|
118
|
+
let optionsGeneration = 0
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Forgets every cached and in-flight options lookup. Called after any write
|
|
122
|
+
* (create, edit, delete), so a picker opened right after creating a record
|
|
123
|
+
* lists it instead of serving the previous 30 s snapshot.
|
|
124
|
+
*/
|
|
125
|
+
export function invalidateOptionsCache(): void {
|
|
126
|
+
optionsGeneration++
|
|
127
|
+
optionsCache.clear()
|
|
128
|
+
optionsInflight.clear()
|
|
129
|
+
}
|
|
116
130
|
|
|
117
131
|
export function optionsRequestKey(
|
|
118
132
|
scope: string,
|
|
@@ -220,6 +234,7 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
|
|
|
220
234
|
optionsInflight.delete(key)
|
|
221
235
|
}
|
|
222
236
|
const epoch = optionsEpoch.get(key) ?? 0
|
|
237
|
+
const generation = optionsGeneration
|
|
223
238
|
const cached = optionsCache.get(key)
|
|
224
239
|
if (cached && Date.now() - cached.at < OPTIONS_TTL_MS) {
|
|
225
240
|
setOptions(cached.payload.options)
|
|
@@ -254,7 +269,7 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
|
|
|
254
269
|
data: part.data,
|
|
255
270
|
meta: part.meta,
|
|
256
271
|
})
|
|
257
|
-
if ((optionsEpoch.get(key) ?? 0) === epoch) {
|
|
272
|
+
if ((optionsEpoch.get(key) ?? 0) === epoch && optionsGeneration === generation) {
|
|
258
273
|
optionsCache.set(key, { payload, at: Date.now() })
|
|
259
274
|
}
|
|
260
275
|
return payload
|
|
@@ -270,7 +285,7 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
|
|
|
270
285
|
throw new Error(body?.message || 'options resolver: unsuccessful response')
|
|
271
286
|
}
|
|
272
287
|
const payload = readOptionsEnvelope(body)
|
|
273
|
-
if ((optionsEpoch.get(key) ?? 0) === epoch) {
|
|
288
|
+
if ((optionsEpoch.get(key) ?? 0) === epoch && optionsGeneration === generation) {
|
|
274
289
|
optionsCache.set(key, { payload, at: Date.now() })
|
|
275
290
|
}
|
|
276
291
|
return payload
|
|
@@ -22,6 +22,7 @@ export const VALIDATION_CATALOGS: Record<string, Record<string, string>> = {
|
|
|
22
22
|
integer: '{{label}} debe ser un entero',
|
|
23
23
|
custom: '{{label}} no es válido',
|
|
24
24
|
line_items_required: '{{label}} requiere al menos un renglón',
|
|
25
|
+
protected_field: 'El campo {{label}} lo administra el sistema y no se puede modificar',
|
|
25
26
|
fallback: '{{label}}: valor inválido',
|
|
26
27
|
},
|
|
27
28
|
en: {
|
|
@@ -43,6 +44,7 @@ export const VALIDATION_CATALOGS: Record<string, Record<string, string>> = {
|
|
|
43
44
|
integer: 'The {{label}} must be an integer',
|
|
44
45
|
custom: 'The {{label}} is invalid',
|
|
45
46
|
line_items_required: '{{label}} requires at least one row',
|
|
47
|
+
protected_field: 'The {{label}} field is managed by the system and cannot be changed',
|
|
46
48
|
fallback: '{{label}}: invalid value',
|
|
47
49
|
},
|
|
48
50
|
}
|
package/vitest.config.ts
CHANGED
package/vitest.setup.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Node 26 exposes a global localStorage getter that returns undefined unless
|
|
2
|
+
// --localStorage-file is set, and that getter hides happy-dom's storage.
|
|
3
|
+
// Tests that persist page size or the metadata cache need a real Storage.
|
|
4
|
+
|
|
5
|
+
function installMemoryStorage(name: 'localStorage' | 'sessionStorage') {
|
|
6
|
+
const current = globalThis[name] as Storage | undefined
|
|
7
|
+
if (current && typeof current.clear === 'function' && typeof current.setItem === 'function') return
|
|
8
|
+
const store = new Map<string, string>()
|
|
9
|
+
const memory: Storage = {
|
|
10
|
+
getItem: (key) => (store.has(key) ? store.get(key)! : null),
|
|
11
|
+
setItem: (key, value) => {
|
|
12
|
+
store.set(String(key), String(value))
|
|
13
|
+
},
|
|
14
|
+
removeItem: (key) => {
|
|
15
|
+
store.delete(String(key))
|
|
16
|
+
},
|
|
17
|
+
clear: () => {
|
|
18
|
+
store.clear()
|
|
19
|
+
},
|
|
20
|
+
key: (index) => Array.from(store.keys())[index] ?? null,
|
|
21
|
+
get length() {
|
|
22
|
+
return store.size
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
Object.defineProperty(globalThis, name, { value: memory, configurable: true, writable: true })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
installMemoryStorage('localStorage')
|
|
29
|
+
installMemoryStorage('sessionStorage')
|