@asteby/metacore-runtime-react 39.2.8 → 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 (41) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/api-context.d.ts.map +1 -1
  3. package/dist/api-context.js +46 -6
  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/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -2
  16. package/dist/permissions-manager.d.ts +9 -0
  17. package/dist/permissions-manager.d.ts.map +1 -1
  18. package/dist/permissions-manager.js +40 -14
  19. package/dist/query-batch.d.ts +16 -2
  20. package/dist/query-batch.d.ts.map +1 -1
  21. package/dist/query-batch.js +66 -20
  22. package/dist/use-options-resolver.d.ts +6 -0
  23. package/dist/use-options-resolver.d.ts.map +1 -1
  24. package/dist/use-options-resolver.js +16 -2
  25. package/dist/validation-catalog.d.ts.map +1 -1
  26. package/dist/validation-catalog.js +2 -0
  27. package/package.json +3 -3
  28. package/src/__tests__/calendar-date.test.ts +22 -0
  29. package/src/__tests__/options-cache-invalidation.test.tsx +126 -0
  30. package/src/__tests__/permissions-manager.test.tsx +73 -0
  31. package/src/api-context.tsx +60 -6
  32. package/src/calendar-date.ts +16 -0
  33. package/src/dialogs/dynamic-record.tsx +6 -1
  34. package/src/dynamic-columns.tsx +3 -1
  35. package/src/dynamic-date-field.tsx +2 -10
  36. package/src/index.ts +2 -0
  37. package/src/permissions-manager.tsx +58 -19
  38. package/src/query-batch.test.ts +78 -1
  39. package/src/query-batch.ts +66 -17
  40. package/src/use-options-resolver.ts +17 -2
  41. package/src/validation-catalog.ts +2 -0
@@ -21,6 +21,7 @@ import {
21
21
  } from '@asteby/metacore-ui/primitives'
22
22
  import { CalendarIcon } from 'lucide-react'
23
23
  import type { ActionFieldDef } from './types'
24
+ import { parseCalendarDate } from './calendar-date'
24
25
 
25
26
  export interface DynamicDateFieldProps {
26
27
  field: ActionFieldDef
@@ -28,16 +29,7 @@ export interface DynamicDateFieldProps {
28
29
  onChange: (v: any) => void
29
30
  }
30
31
 
31
- // Parse "YYYY-MM-DD" (or any Date-parseable string) into a local Date, or
32
- // undefined when empty/invalid. Uses noon to dodge timezone day-shift.
33
- function toDate(v: any): Date | undefined {
34
- if (!v) return undefined
35
- const s = String(v)
36
- const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
37
- if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12)
38
- const d = new Date(s)
39
- return isNaN(d.getTime()) ? undefined : d
40
- }
32
+ const toDate = parseCalendarDate
41
33
 
42
34
  // Format a Date back to "YYYY-MM-DD" (local, no timezone shift).
43
35
  function toISO(d: Date): string {
package/src/index.ts CHANGED
@@ -400,6 +400,7 @@ export {
400
400
  optionsBatchToken,
401
401
  optionsModelFromUrl,
402
402
  rememberInRows,
403
+ invalidateQueryBatchData,
403
404
  resetQueryBatchCache,
404
405
  splitInToken,
405
406
  tokenForGet,
@@ -409,6 +410,7 @@ export {
409
410
  } from './query-batch'
410
411
  export {
411
412
  useOptionsResolver,
413
+ invalidateOptionsCache,
412
414
  projectOption,
413
415
  type ResolvedOption,
414
416
  type OptionsMeta,
@@ -38,6 +38,7 @@ import {
38
38
  Trash2,
39
39
  } from 'lucide-react'
40
40
  import { toast } from 'sonner'
41
+ import { extractServerError } from './server-error'
41
42
  import { cn } from '@asteby/metacore-ui/lib'
42
43
  import {
43
44
  AlertDialog,
@@ -397,6 +398,29 @@ export function normalizeCatalogGroups(catalog: PermissionsCatalog): ModuleGroup
397
398
  }
398
399
 
399
400
  /** Flat list of every module across groups, in render order. */
401
+ /** Identity of one picker entry: its group index and its index in the group. */
402
+ export function moduleEntryId(groupIndex: number, moduleIndex: number): string {
403
+ return `${groupIndex}:${moduleIndex}`
404
+ }
405
+
406
+ /** The first entry of the catalog, or null when it has no modules. */
407
+ export function firstModuleEntryId(groups: ModuleGroup[]): string | null {
408
+ const gi = groups.findIndex((g) => g.modules.length > 0)
409
+ return gi < 0 ? null : moduleEntryId(gi, 0)
410
+ }
411
+
412
+ /** Resolve an entry id back to its module and group. */
413
+ export function moduleAtEntry(
414
+ groups: ModuleGroup[] | null | undefined,
415
+ id: string | null,
416
+ ): { module: PermissionModuleDef; group: ModuleGroup } | null {
417
+ if (!groups || !id) return null
418
+ const [gi, mi] = id.split(':').map(Number)
419
+ const group = groups[gi!]
420
+ const module = group?.modules[mi!]
421
+ return group && module ? { module, group } : null
422
+ }
423
+
400
424
  export function flattenGroups(groups: ModuleGroup[]): PermissionModuleDef[] {
401
425
  return groups.flatMap((g) => g.modules)
402
426
  }
@@ -546,6 +570,10 @@ export function PermissionsManager({
546
570
  const [loadError, setLoadError] = React.useState(false)
547
571
 
548
572
  const [activeRoleId, setActiveRoleId] = React.useState<string | null>(null)
573
+ // The picker selection is the ENTRY (group index : module index), not the
574
+ // module key: two sidebar entries may govern the same model (e.g. "Ventas
575
+ // POS" and "Por cobrar" both over sales_orders) and a key-based lookup
576
+ // always landed on the first of them.
549
577
  const [activeModuleKey, setActiveModuleKey] = React.useState<string | null>(null)
550
578
 
551
579
  // baseline = capabilities as persisted; draft = baseline + local edits.
@@ -567,6 +595,8 @@ export function PermissionsManager({
567
595
  color: string
568
596
  icon: string
569
597
  grantAll: boolean
598
+ /** Server rejection shown inside the dialog (e.g. duplicate name). */
599
+ error?: string
570
600
  }>({
571
601
  open: false,
572
602
  mode: 'create',
@@ -594,9 +624,7 @@ export function PermissionsManager({
594
624
  setGeneral(cat.general ?? [])
595
625
  setRoles(rs)
596
626
  setActiveRoleId((prev) => prev ?? rs[0]?.id ?? null)
597
- setActiveModuleKey(
598
- (prev) => prev ?? flattenGroups(grouped)[0]?.key ?? null,
599
- )
627
+ setActiveModuleKey((prev) => prev ?? firstModuleEntryId(grouped))
600
628
  })
601
629
  .catch(() => {
602
630
  if (!cancelled) setLoadError(true)
@@ -642,8 +670,8 @@ export function PermissionsManager({
642
670
  [roles, activeRoleId],
643
671
  )
644
672
  const activeModule = React.useMemo(
645
- () => allModules.find((m) => m.key === activeModuleKey) ?? null,
646
- [allModules, activeModuleKey],
673
+ () => moduleAtEntry(groups, activeModuleKey)?.module ?? null,
674
+ [groups, activeModuleKey],
647
675
  )
648
676
 
649
677
  const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft)
@@ -730,6 +758,7 @@ export function PermissionsManager({
730
758
  const label = roleDialog.label.trim()
731
759
  if (!label) return
732
760
  setRoleSaving(true)
761
+ setRoleDialog((d) => ({ ...d, error: undefined }))
733
762
  try {
734
763
  if (roleDialog.mode === 'create' && createRole) {
735
764
  const created = await createRole({
@@ -764,10 +793,15 @@ export function PermissionsManager({
764
793
  toast.success('Rol actualizado')
765
794
  }
766
795
  setRoleDialog((d) => ({ ...d, open: false }))
767
- } catch {
768
- toast.error(
769
- roleDialog.mode === 'create' ? 'No se pudo crear el rol' : 'No se pudo actualizar el rol',
770
- )
796
+ } catch (err) {
797
+ // Keep the dialog open WITH the server's reason (422 duplicate name,
798
+ // 403 …): a toast alone can sit behind the modal overlay.
799
+ const fallback =
800
+ roleDialog.mode === 'create' ? 'No se pudo crear el rol' : 'No se pudo actualizar el rol'
801
+ const { title, description } = extractServerError(err, fallback)
802
+ const error = description ? `${title}: ${description}` : title
803
+ setRoleDialog((d) => ({ ...d, error }))
804
+ toast.error(fallback, { description: error })
771
805
  } finally {
772
806
  setRoleSaving(false)
773
807
  }
@@ -806,11 +840,10 @@ export function PermissionsManager({
806
840
  const moduleGranted = activeModule && draft ? grantedCountForModule(draft, activeModule) : 0
807
841
  const moduleTotal = activeModule?.actions.length ?? 0
808
842
  const checksDisabled = !activeRole || !draft || loadingPerms || saving
809
- const activeModuleGroupTitle = React.useMemo(() => {
810
- if (!activeModule || !groups) return ''
811
- const g = groups.find((grp) => grp.modules.some((m) => m.key === activeModule.key))
812
- return g?.title ?? ''
813
- }, [activeModule, groups])
843
+ const activeModuleGroupTitle = React.useMemo(
844
+ () => moduleAtEntry(groups, activeModuleKey)?.group.title ?? '',
845
+ [groups, activeModuleKey],
846
+ )
814
847
 
815
848
  // ---- render --------------------------------------------------------------
816
849
  if (loadError) {
@@ -1094,12 +1127,12 @@ export function PermissionsManager({
1094
1127
  key={group.title || `__untitled_${gi}`}
1095
1128
  heading={group.title || undefined}
1096
1129
  >
1097
- {group.modules.map((mod) => (
1130
+ {group.modules.map((mod, mi) => (
1098
1131
  <CommandItem
1099
- key={mod.key}
1100
- value={`${group.title} ${mod.label} ${mod.key}`}
1132
+ key={moduleEntryId(gi, mi)}
1133
+ value={`${group.title} ${mod.label} ${mod.key} ${moduleEntryId(gi, mi)}`}
1101
1134
  onSelect={() => {
1102
- setActiveModuleKey(mod.key)
1135
+ setActiveModuleKey(moduleEntryId(gi, mi))
1103
1136
  setModuleOpen(false)
1104
1137
  }}
1105
1138
  >
@@ -1129,7 +1162,7 @@ export function PermissionsManager({
1129
1162
  /{mod.actions.length}
1130
1163
  </Badge>
1131
1164
  )}
1132
- {mod.key === activeModuleKey && (
1165
+ {moduleEntryId(gi, mi) === activeModuleKey && (
1133
1166
  <Check className="ml-2 h-4 w-4 shrink-0" />
1134
1167
  )}
1135
1168
  </CommandItem>
@@ -1270,6 +1303,7 @@ export function PermissionsManager({
1270
1303
  setRoleDialog((d) => ({
1271
1304
  ...d,
1272
1305
  label,
1306
+ error: undefined,
1273
1307
  icon:
1274
1308
  d.icon === suggestRoleIcon(d.label) ||
1275
1309
  d.icon === 'Shield' ||
@@ -1330,6 +1364,11 @@ export function PermissionsManager({
1330
1364
  </span>
1331
1365
  </label>
1332
1366
  )}
1367
+ {roleDialog.error && (
1368
+ <p role="alert" className="text-sm text-destructive">
1369
+ {roleDialog.error}
1370
+ </p>
1371
+ )}
1333
1372
  </div>
1334
1373
  <DialogFooter>
1335
1374
  <Button
@@ -1,5 +1,7 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { describe, expect, it, vi } from 'vitest'
2
2
  import {
3
+ invalidateQueryBatchData,
4
+ loadQueryPart,
3
5
  nameBatchTokens,
4
6
  narrowInToken,
5
7
  optionsBatchToken,
@@ -64,4 +66,79 @@ describe('query batch tokens', () => {
64
66
  expect(tokenForGet('/data/Stock/me')).toBeNull()
65
67
  expect(tokenForGet('/options/Customer?field=name&q=lop')).toBe('o:Customer?field=name&q=lop')
66
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
+ })
67
144
  })
@@ -31,7 +31,26 @@ type Waiter = {
31
31
  }
32
32
 
33
33
  type CachedPart = { part: QueryPart; at: number }
34
- type CachedEntity = { row: Record<string, unknown> | null; 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
+ }
35
54
 
36
55
  const partCache = new Map<string, CachedPart>()
37
56
  const entityCache = new Map<string, CachedEntity>()
@@ -42,13 +61,18 @@ export interface InListToken {
42
61
  shape: string
43
62
  }
44
63
 
45
- /** A concrete `t:` `in:` list. `@` references stay whole: the ids are not known yet. */
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
+ */
46
69
  export function splitInToken(token: string): InListToken | null {
47
70
  const q = token.indexOf('?')
48
71
  if (q < 0 || !token.startsWith('t:')) return null
49
72
  const model = token.slice(2, q)
50
73
  if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(model)) return null
51
74
  const params = new URLSearchParams(token.slice(q + 1))
75
+ if (params.has('page')) return null
52
76
  let field = ''
53
77
  let ids: string[] = []
54
78
  for (const [key, value] of params.entries()) {
@@ -71,6 +95,11 @@ function entityKey(scope: string, shape: string, id: string): string {
71
95
  return `${scope}\n${shape}\n${id}`
72
96
  }
73
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
+
74
103
  function replaceInIds(token: string, field: string, ids: string[]): string {
75
104
  const q = token.indexOf('?')
76
105
  const params = new URLSearchParams(token.slice(q + 1))
@@ -99,7 +128,7 @@ export function narrowInToken(token: string, now = Date.now()): NarrowedIn | nul
99
128
  missingIds.push(id)
100
129
  continue
101
130
  }
102
- if (hit.row) rows.push(hit.row)
131
+ rows.push(...hit.rows)
103
132
  }
104
133
  if (missingIds.length === parsed.ids.length) return null
105
134
  return {
@@ -111,7 +140,11 @@ export function narrowInToken(token: string, now = Date.now()): NarrowedIn | nul
111
140
  }
112
141
  }
113
142
 
114
- /** Stores one row per requested id. An id with no row is remembered as empty. */
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
+ */
115
148
  export function rememberInRows(
116
149
  token: string,
117
150
  ids: string[],
@@ -121,22 +154,19 @@ export function rememberInRows(
121
154
  const parsed = splitInToken(token)
122
155
  if (!parsed) return
123
156
  const scope = cacheScope()
124
- const seen = new Set<string>()
157
+ const field = rowField(parsed.field)
158
+ const byId = new Map<string, Record<string, unknown>[]>()
159
+ for (const id of ids) byId.set(id, [])
125
160
  if (Array.isArray(rows)) {
126
161
  for (const row of rows) {
127
162
  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
- })
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>)
135
166
  }
136
167
  }
137
- for (const id of ids) {
138
- if (seen.has(id)) continue
139
- entityCache.set(entityKey(scope, parsed.shape, id), { row: null, at: now })
168
+ for (const [id, bucket] of byId) {
169
+ entityCache.set(entityKey(scope, parsed.shape, id), { rows: bucket, at: now })
140
170
  }
141
171
  }
142
172
 
@@ -147,6 +177,19 @@ export function resetQueryBatchCache(): void {
147
177
  if (timer != null) clearTimeout(timer)
148
178
  timer = null
149
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
+ }
150
193
  let waiters: Waiter[] = []
151
194
  let timer: ReturnType<typeof setTimeout> | null = null
152
195
  let transport: ApiClient | null = null
@@ -205,7 +248,7 @@ export function nameBatchTokens(tokens: string[]): { name: string; token: string
205
248
 
206
249
  export function loadQueryPart(api: ApiClient, token: string): Promise<QueryPart> {
207
250
  const cached = partCache.get(`${cacheScope()}\n${token}`)
208
- 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) {
209
252
  return Promise.resolve(cached.part)
210
253
  }
211
254
  const narrowed = narrowInToken(token)
@@ -239,7 +282,8 @@ async function flushQueryBatch() {
239
282
  const scope = cacheScope()
240
283
  for (const part of named) {
241
284
  const cached = partCache.get(`${scope}\n${part.token}`)
242
- 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
243
287
  }
244
288
  try {
245
289
  const res = await api.post('/q', { parts: named.map((part) => part.plan), inm })
@@ -257,9 +301,14 @@ async function flushQueryBatch() {
257
301
  if (part.not_modified) {
258
302
  const cached = partCache.get(`${scope}\n${waiter.token}`)
259
303
  if (cached) {
304
+ cached.at = Date.now()
260
305
  waiter.resolve(cached.part)
261
306
  continue
262
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
263
312
  }
264
313
  const merged = mergeCachedRows(waiter.cachedRows, part)
265
314
  if (waiter.missingIds && waiter.field) {
@@ -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
  }