@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/api-context.d.ts.map +1 -1
  3. package/dist/api-context.js +50 -2
  4. package/dist/calendar-date.d.ts +4 -0
  5. package/dist/calendar-date.d.ts.map +1 -0
  6. package/dist/calendar-date.js +18 -0
  7. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  8. package/dist/dialogs/dynamic-record.js +6 -1
  9. package/dist/dynamic-columns.d.ts.map +1 -1
  10. package/dist/dynamic-columns.js +3 -1
  11. package/dist/dynamic-date-field.d.ts.map +1 -1
  12. package/dist/dynamic-date-field.js +2 -12
  13. package/dist/dynamic-table.d.ts.map +1 -1
  14. package/dist/dynamic-table.js +8 -2
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -2
  18. package/dist/list-include.d.ts +9 -0
  19. package/dist/list-include.d.ts.map +1 -0
  20. package/dist/list-include.js +36 -0
  21. package/dist/permissions-manager.d.ts +9 -0
  22. package/dist/permissions-manager.d.ts.map +1 -1
  23. package/dist/permissions-manager.js +40 -14
  24. package/dist/query-batch.d.ts +48 -0
  25. package/dist/query-batch.d.ts.map +1 -1
  26. package/dist/query-batch.js +251 -8
  27. package/dist/use-options-resolver.d.ts +6 -0
  28. package/dist/use-options-resolver.d.ts.map +1 -1
  29. package/dist/use-options-resolver.js +16 -2
  30. package/dist/validation-catalog.d.ts.map +1 -1
  31. package/dist/validation-catalog.js +2 -0
  32. package/package.json +3 -3
  33. package/src/__tests__/calendar-date.test.ts +22 -0
  34. package/src/__tests__/options-cache-invalidation.test.tsx +126 -0
  35. package/src/__tests__/permissions-manager.test.tsx +73 -0
  36. package/src/api-context.tsx +64 -2
  37. package/src/calendar-date.ts +16 -0
  38. package/src/dialogs/dynamic-record.tsx +6 -1
  39. package/src/dynamic-columns.tsx +3 -1
  40. package/src/dynamic-date-field.tsx +2 -10
  41. package/src/dynamic-table.tsx +10 -2
  42. package/src/index.ts +10 -0
  43. package/src/list-include.test.ts +33 -0
  44. package/src/list-include.ts +34 -0
  45. package/src/permissions-manager.tsx +58 -19
  46. package/src/query-batch.test.ts +122 -2
  47. package/src/query-batch.ts +274 -7
  48. package/src/use-options-resolver.ts +17 -2
  49. package/src/validation-catalog.ts +2 -0
  50. package/vitest.config.ts +1 -0
  51. package/vitest.setup.ts +29 -0
@@ -1,5 +1,16 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { nameBatchTokens, optionsBatchToken, optionsModelFromUrl } from './query-batch'
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
  })
@@ -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({ token, resolve, reject })
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.token))
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
- if (cached?.part.etag) inm[part.name] = cached.part.etag
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.token)
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(part)
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
@@ -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
  })
@@ -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')