@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
@@ -14,6 +14,39 @@ export interface QueryPart {
14
14
  etag?: string;
15
15
  not_modified?: boolean;
16
16
  }
17
+ export interface InListToken {
18
+ field: string;
19
+ ids: string[];
20
+ shape: string;
21
+ }
22
+ /**
23
+ * A concrete `t:` `in:` list. `@` references stay whole: the ids are not known
24
+ * yet. A paginated list (`page=`) is not split: serving part of it from memory
25
+ * would change which rows land on the page and the total.
26
+ */
27
+ export declare function splitInToken(token: string): InListToken | null;
28
+ export interface NarrowedIn {
29
+ wire: string;
30
+ rows: Record<string, unknown>[];
31
+ missingIds: string[];
32
+ field: string;
33
+ shape: string;
34
+ }
35
+ /** Drops ids already cached for this shape. An empty wire means nothing has to travel. */
36
+ export declare function narrowInToken(token: string, now?: number): NarrowedIn | null;
37
+ /**
38
+ * Stores every row of each requested id (one id can match many rows: the
39
+ * stock of a product in several warehouses). An id with no row is remembered
40
+ * as empty.
41
+ */
42
+ export declare function rememberInRows(token: string, ids: string[], rows: unknown, now?: number): void;
43
+ export declare function resetQueryBatchCache(): void;
44
+ /**
45
+ * Forgets every row, record and option read. useApi calls it after each
46
+ * mutation so the list that just gained, changed or lost a row reads it
47
+ * again instead of the copy from before the save. Metadata and i18n stay.
48
+ */
49
+ export declare function invalidateQueryBatchData(): void;
17
50
  export declare function optionsModelFromUrl(url: string): string | null;
18
51
  export declare function optionsBatchToken(model: string, field: string, query: string, limit: number | undefined, filter: string | undefined): string;
19
52
  /** Names each distinct token so the server can key the response. */
@@ -23,4 +56,19 @@ export declare function nameBatchTokens(tokens: string[]): {
23
56
  plan: string;
24
57
  }[];
25
58
  export declare function loadQueryPart(api: ApiClient, token: string): Promise<QueryPart>;
59
+ /**
60
+ * Token for a host GET that the batch can serve. Custom endpoints, aggregates
61
+ * and `/me` stay on the legacy GET.
62
+ */
63
+ export declare function tokenForGet(url: string, params?: Record<string, unknown>): string | null;
64
+ /**
65
+ * GET through the batch when the URL is a list, table metadata, modal metadata
66
+ * or options read. Anything the batch refuses falls back to the original GET.
67
+ */
68
+ export declare function batchGet(api: ApiClient, url: string, config?: {
69
+ params?: Record<string, unknown>;
70
+ }): Promise<{
71
+ data: any;
72
+ headers?: any;
73
+ }>;
26
74
  //# sourceMappingURL=query-batch.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"query-batch.d.ts","sourceRoot":"","sources":["../src/query-batch.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAE9C,MAAM,WAAW,SAAS;IACtB,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,YAAY,CAAC,EAAE,OAAO,CAAA;CACzB;AA6BD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG9D;AAED,wBAAgB,iBAAiB,CAC7B,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,MAAM,EAAE,MAAM,GAAG,SAAS,GAC3B,MAAM,CAOR;AAED,oEAAoE;AACpE,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAYjG;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAU/E"}
1
+ {"version":3,"file":"query-batch.d.ts","sourceRoot":"","sources":["../src/query-batch.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAE9C,MAAM,WAAW,SAAS;IACtB,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,YAAY,CAAC,EAAE,OAAO,CAAA;CACzB;AAyCD,MAAM,WAAW,WAAW;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,EAAE,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CAChB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAuB9D;AAkBD,MAAM,WAAW,UAAU;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAA;IAC/B,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CAChB;AAED,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,SAAa,GAAG,UAAU,GAAG,IAAI,CAsBhF;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,EAAE,EACb,IAAI,EAAE,OAAO,EACb,GAAG,SAAa,GACjB,IAAI,CAkBN;AAED,wBAAgB,oBAAoB,IAAI,IAAI,CAM3C;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAM/C;AAsBD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG9D;AAED,wBAAgB,iBAAiB,CAC7B,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,MAAM,EAAE,MAAM,GAAG,SAAS,GAC3B,MAAM,CAOR;AAED,oEAAoE;AACpE,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAYjG;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAsB/E;AAmED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,GAAG,IAAI,CA2BxF;AAMD;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE;;;GAwBxG"}
@@ -1,9 +1,157 @@
1
1
  const WINDOW_MS = 8;
2
2
  const PART_TTL_MS = 30_000;
3
+ const ENTITY_TTL_MS = 30_000;
4
+ /**
5
+ * Only metadata and i18n are served from memory without asking the server.
6
+ * Rows (`t:` lists, `r:` records) and options change with every save: they
7
+ * always travel, and an unchanged payload comes back as `not_modified` when
8
+ * the server's etag hashes the content.
9
+ */
10
+ function servedFromMemory(token) {
11
+ return token.startsWith('m:') || token.startsWith('mm:') || token.startsWith('i:');
12
+ }
13
+ /**
14
+ * Etags the server derives from the payload itself (`"qh-…"`). The older
15
+ * `"q-<seed>-<len>"` ones only counted rows or bytes, so a value edited to one
16
+ * of the same length came back `not_modified` with the old data.
17
+ */
18
+ function contentEtag(etag) {
19
+ return !!etag && etag.startsWith('"qh-');
20
+ }
3
21
  const partCache = new Map();
22
+ const entityCache = new Map();
23
+ /**
24
+ * A concrete `t:` `in:` list. `@` references stay whole: the ids are not known
25
+ * yet. A paginated list (`page=`) is not split: serving part of it from memory
26
+ * would change which rows land on the page and the total.
27
+ */
28
+ export function splitInToken(token) {
29
+ const q = token.indexOf('?');
30
+ if (q < 0 || !token.startsWith('t:'))
31
+ return null;
32
+ const model = token.slice(2, q);
33
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(model))
34
+ return null;
35
+ const params = new URLSearchParams(token.slice(q + 1));
36
+ if (params.has('page'))
37
+ return null;
38
+ let field = '';
39
+ let ids = [];
40
+ for (const [key, value] of params.entries()) {
41
+ if (!value.startsWith('in:'))
42
+ continue;
43
+ if (field || value.includes('@'))
44
+ return null;
45
+ field = key;
46
+ ids = value
47
+ .slice(3)
48
+ .split(',')
49
+ .map((id) => id.trim())
50
+ .filter(Boolean);
51
+ }
52
+ if (!field || ids.length === 0)
53
+ return null;
54
+ const keys = [...new Set(params.keys())].sort();
55
+ const bits = keys.map((key) => `${key}=${key === field ? 'in:$1' : (params.get(key) ?? '')}`);
56
+ return { field, ids, shape: `t:${model}?${bits.join('&')}` };
57
+ }
58
+ function entityKey(scope, shape, id) {
59
+ return `${scope}\n${shape}\n${id}`;
60
+ }
61
+ /** The row column an `in:` filter matches: `f_product_id` filters `product_id`. */
62
+ function rowField(param) {
63
+ return param.startsWith('f_') ? param.slice(2) : param;
64
+ }
65
+ function replaceInIds(token, field, ids) {
66
+ const q = token.indexOf('?');
67
+ const params = new URLSearchParams(token.slice(q + 1));
68
+ params.set(field, `in:${ids.join(',')}`);
69
+ return `${token.slice(0, q)}?${params.toString()}`;
70
+ }
71
+ /** Drops ids already cached for this shape. An empty wire means nothing has to travel. */
72
+ export function narrowInToken(token, now = Date.now()) {
73
+ const parsed = splitInToken(token);
74
+ if (!parsed)
75
+ return null;
76
+ const scope = cacheScope();
77
+ const rows = [];
78
+ const missingIds = [];
79
+ for (const id of parsed.ids) {
80
+ const hit = entityCache.get(entityKey(scope, parsed.shape, id));
81
+ if (!hit || now - hit.at >= ENTITY_TTL_MS) {
82
+ missingIds.push(id);
83
+ continue;
84
+ }
85
+ rows.push(...hit.rows);
86
+ }
87
+ if (missingIds.length === parsed.ids.length)
88
+ return null;
89
+ return {
90
+ wire: missingIds.length === 0 ? '' : replaceInIds(token, parsed.field, missingIds),
91
+ rows,
92
+ missingIds,
93
+ field: parsed.field,
94
+ shape: parsed.shape,
95
+ };
96
+ }
97
+ /**
98
+ * Stores every row of each requested id (one id can match many rows: the
99
+ * stock of a product in several warehouses). An id with no row is remembered
100
+ * as empty.
101
+ */
102
+ export function rememberInRows(token, ids, rows, now = Date.now()) {
103
+ const parsed = splitInToken(token);
104
+ if (!parsed)
105
+ return;
106
+ const scope = cacheScope();
107
+ const field = rowField(parsed.field);
108
+ const byId = new Map();
109
+ for (const id of ids)
110
+ byId.set(id, []);
111
+ if (Array.isArray(rows)) {
112
+ for (const row of rows) {
113
+ if (!row || typeof row !== 'object')
114
+ continue;
115
+ const id = String(row[field] ?? '').trim();
116
+ const bucket = byId.get(id);
117
+ if (bucket)
118
+ bucket.push(row);
119
+ }
120
+ }
121
+ for (const [id, bucket] of byId) {
122
+ entityCache.set(entityKey(scope, parsed.shape, id), { rows: bucket, at: now });
123
+ }
124
+ }
125
+ export function resetQueryBatchCache() {
126
+ partCache.clear();
127
+ entityCache.clear();
128
+ waiters = [];
129
+ if (timer != null)
130
+ clearTimeout(timer);
131
+ timer = null;
132
+ }
133
+ /**
134
+ * Forgets every row, record and option read. useApi calls it after each
135
+ * mutation so the list that just gained, changed or lost a row reads it
136
+ * again instead of the copy from before the save. Metadata and i18n stay.
137
+ */
138
+ export function invalidateQueryBatchData() {
139
+ for (const key of [...partCache.keys()]) {
140
+ const token = key.slice(key.indexOf('\n') + 1);
141
+ if (!servedFromMemory(token))
142
+ partCache.delete(key);
143
+ }
144
+ entityCache.clear();
145
+ }
4
146
  let waiters = [];
5
147
  let timer = null;
6
148
  let transport = null;
149
+ function mergeCachedRows(cached, part) {
150
+ if (!cached?.length)
151
+ return part;
152
+ const fresh = Array.isArray(part.data) ? part.data : [];
153
+ return { ...part, data: [...cached, ...fresh] };
154
+ }
7
155
  function cacheScope() {
8
156
  if (typeof localStorage === 'undefined')
9
157
  return '';
@@ -49,12 +197,24 @@ export function nameBatchTokens(tokens) {
49
197
  }
50
198
  export function loadQueryPart(api, token) {
51
199
  const cached = partCache.get(`${cacheScope()}\n${token}`);
52
- if (cached && Date.now() - cached.at < PART_TTL_MS && cached.part.success) {
200
+ if (cached && servedFromMemory(token) && Date.now() - cached.at < PART_TTL_MS && cached.part.success) {
53
201
  return Promise.resolve(cached.part);
54
202
  }
203
+ const narrowed = narrowInToken(token);
204
+ if (narrowed && narrowed.wire === '') {
205
+ return Promise.resolve({ success: true, data: narrowed.rows, meta: { total: narrowed.rows.length } });
206
+ }
55
207
  return new Promise((resolve, reject) => {
56
208
  transport = api;
57
- waiters.push({ token, resolve, reject });
209
+ waiters.push({
210
+ token,
211
+ wire: narrowed?.wire ?? token,
212
+ resolve,
213
+ reject,
214
+ cachedRows: narrowed?.rows,
215
+ missingIds: narrowed?.missingIds,
216
+ field: narrowed?.field,
217
+ });
58
218
  if (timer == null)
59
219
  timer = setTimeout(() => void flushQueryBatch(), WINDOW_MS);
60
220
  });
@@ -66,14 +226,15 @@ async function flushQueryBatch() {
66
226
  const api = transport;
67
227
  if (!api || batch.length === 0)
68
228
  return;
69
- const named = nameBatchTokens(batch.map((waiter) => waiter.token));
229
+ const named = nameBatchTokens(batch.map((waiter) => waiter.wire));
70
230
  const byToken = new Map(named.map((part) => [part.token, part.name]));
71
231
  const inm = {};
72
232
  const scope = cacheScope();
73
233
  for (const part of named) {
74
234
  const cached = partCache.get(`${scope}\n${part.token}`);
75
- if (cached?.part.etag)
76
- inm[part.name] = cached.part.etag;
235
+ const etag = cached?.part.etag;
236
+ if (etag && (servedFromMemory(part.token) || contentEtag(etag)))
237
+ inm[part.name] = etag;
77
238
  }
78
239
  try {
79
240
  const res = await api.post('/q', { parts: named.map((part) => part.plan), inm });
@@ -82,7 +243,7 @@ async function flushQueryBatch() {
82
243
  throw new Error('batch unavailable');
83
244
  }
84
245
  for (const waiter of batch) {
85
- const name = byToken.get(waiter.token);
246
+ const name = byToken.get(waiter.wire);
86
247
  const part = name ? body.parts[name] : undefined;
87
248
  if (!part) {
88
249
  waiter.reject(new Error('batch part missing'));
@@ -91,14 +252,23 @@ async function flushQueryBatch() {
91
252
  if (part.not_modified) {
92
253
  const cached = partCache.get(`${scope}\n${waiter.token}`);
93
254
  if (cached) {
255
+ cached.at = Date.now();
94
256
  waiter.resolve(cached.part);
95
257
  continue;
96
258
  }
259
+ // Invalidated while the request was in flight: nothing to
260
+ // reuse, so batchGet falls back to the plain GET.
261
+ waiter.reject(new Error('batch part not modified but not cached'));
262
+ continue;
263
+ }
264
+ const merged = mergeCachedRows(waiter.cachedRows, part);
265
+ if (waiter.missingIds && waiter.field) {
266
+ rememberInRows(waiter.token, waiter.missingIds, part.data);
97
267
  }
98
268
  if (part.success) {
99
- partCache.set(`${scope}\n${waiter.token}`, { part, at: Date.now() });
269
+ partCache.set(`${scope}\n${waiter.token}`, { part: merged, at: Date.now() });
100
270
  }
101
- waiter.resolve(part);
271
+ waiter.resolve(merged);
102
272
  }
103
273
  }
104
274
  catch (err) {
@@ -107,3 +277,76 @@ async function flushQueryBatch() {
107
277
  waiter.reject(error);
108
278
  }
109
279
  }
280
+ function canonicalParams(params) {
281
+ if (!params)
282
+ return '';
283
+ const keys = Object.keys(params)
284
+ .filter((key) => params[key] != null && params[key] !== '')
285
+ .sort();
286
+ const search = new URLSearchParams();
287
+ for (const key of keys)
288
+ search.set(key, String(params[key]));
289
+ return search.toString();
290
+ }
291
+ /**
292
+ * Token for a host GET that the batch can serve. Custom endpoints, aggregates
293
+ * and `/me` stay on the legacy GET.
294
+ */
295
+ export function tokenForGet(url, params) {
296
+ const [path, query = ''] = url.split('?');
297
+ const table = path.match(/^\/metadata\/table\/([A-Za-z][A-Za-z0-9_]*)$/);
298
+ if (table)
299
+ return `m:${table[1]}`;
300
+ const modal = path.match(/^\/metadata\/modal\/([A-Za-z][A-Za-z0-9_]*)$/);
301
+ if (modal)
302
+ return `mm:${modal[1]}`;
303
+ const list = path.match(/^\/data\/([A-Za-z][A-Za-z0-9_]*)$/);
304
+ if (list) {
305
+ const q = canonicalParams(params);
306
+ return q ? `t:${list[1]}?${q}` : `t:${list[1]}`;
307
+ }
308
+ const opt = path.match(/^\/options\/([A-Za-z][A-Za-z0-9_]*)$/);
309
+ if (!opt)
310
+ return null;
311
+ const search = new URLSearchParams(query);
312
+ if (params) {
313
+ for (const [key, value] of Object.entries(params)) {
314
+ if (value != null && value !== '')
315
+ search.set(key, String(value));
316
+ }
317
+ }
318
+ const limit = Number(search.get('limit'));
319
+ return optionsBatchToken(opt[1], search.get('field') || 'id', search.get('q') || search.get('search') || '', Number.isFinite(limit) && limit > 0 ? limit : undefined, search.get('filter_value') || undefined);
320
+ }
321
+ function batchForbidden(message) {
322
+ return /forbidden|permiso/i.test(message || '');
323
+ }
324
+ /**
325
+ * GET through the batch when the URL is a list, table metadata, modal metadata
326
+ * or options read. Anything the batch refuses falls back to the original GET.
327
+ */
328
+ export async function batchGet(api, url, config) {
329
+ const token = tokenForGet(url, config?.params);
330
+ if (!token)
331
+ return api.get(url, config);
332
+ try {
333
+ const part = await loadQueryPart(api, token);
334
+ if (!part.success) {
335
+ if (batchForbidden(part.message)) {
336
+ const err = new Error(part.message || 'forbidden');
337
+ err.status = 403;
338
+ err.response = { status: 403 };
339
+ throw err;
340
+ }
341
+ return api.get(url, config);
342
+ }
343
+ return { data: { success: true, data: part.data, meta: part.meta } };
344
+ }
345
+ catch (err) {
346
+ const status = err?.status
347
+ ?? err?.response?.status;
348
+ if (status === 403)
349
+ throw err;
350
+ return api.get(url, config);
351
+ }
352
+ }
@@ -76,6 +76,12 @@ export interface UseOptionsResolverResult {
76
76
  /** Forces a refetch. Useful after a parent record updates. */
77
77
  refetch: () => void;
78
78
  }
79
+ /**
80
+ * Forgets every cached and in-flight options lookup. Called after any write
81
+ * (create, edit, delete), so a picker opened right after creating a record
82
+ * lists it instead of serving the previous 30 s snapshot.
83
+ */
84
+ export declare function invalidateOptionsCache(): void;
79
85
  export declare function optionsRequestKey(scope: string, url: string, field: string, query: string, limit: number | undefined, filter: string | undefined): string;
80
86
  export declare function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsResolverResult;
81
87
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"use-options-resolver.d.ts","sourceRoot":"","sources":["../src/use-options-resolver.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,cAAc;IAC3B,8CAA8C;IAC9C,EAAE,EAAE,MAAM,GAAG,MAAM,CAAA;IACnB,2DAA2D;IAC3D,KAAK,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,sBAAsB;IACtB,KAAK,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,MAAM,WAAW,WAAW;IACxB,oEAAoE;IACpE,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAA;IACnC,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,sBAAsB;IACnC;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,wBAAwB;IACrC,OAAO,EAAE,cAAc,EAAE,CAAA;IACzB,IAAI,EAAE,WAAW,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,8DAA8D;IAC9D,OAAO,EAAE,MAAM,IAAI,CAAA;CACtB;AAoBD,wBAAgB,iBAAiB,CAC7B,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,MAAM,EAAE,MAAM,GAAG,SAAS,GAC3B,MAAM,CAER;AA8BD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,sBAAsB,GAAG,wBAAwB,CA4JzF;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,GAAG,GAAG,cAAc,CAatD"}
1
+ {"version":3,"file":"use-options-resolver.d.ts","sourceRoot":"","sources":["../src/use-options-resolver.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,cAAc;IAC3B,8CAA8C;IAC9C,EAAE,EAAE,MAAM,GAAG,MAAM,CAAA;IACnB,2DAA2D;IAC3D,KAAK,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,sBAAsB;IACtB,KAAK,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,MAAM,WAAW,WAAW;IACxB,oEAAoE;IACpE,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAA;IACnC,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,sBAAsB;IACnC;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,wBAAwB;IACrC,OAAO,EAAE,cAAc,EAAE,CAAA;IACzB,IAAI,EAAE,WAAW,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,8DAA8D;IAC9D,OAAO,EAAE,MAAM,IAAI,CAAA;CACtB;AAuBD;;;;GAIG;AACH,wBAAgB,sBAAsB,IAAI,IAAI,CAI7C;AAED,wBAAgB,iBAAiB,CAC7B,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,MAAM,EAAE,MAAM,GAAG,SAAS,GAC3B,MAAM,CAER;AA8BD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,sBAAsB,GAAG,wBAAwB,CA6JzF;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,GAAG,GAAG,cAAc,CAatD"}
@@ -27,6 +27,19 @@ const OPTIONS_TTL_MS = 30_000;
27
27
  const optionsCache = new Map();
28
28
  const optionsInflight = new Map();
29
29
  const optionsEpoch = new Map();
30
+ // Bumped by invalidateOptionsCache: a lookup that started before a write must
31
+ // not store what it read.
32
+ let optionsGeneration = 0;
33
+ /**
34
+ * Forgets every cached and in-flight options lookup. Called after any write
35
+ * (create, edit, delete), so a picker opened right after creating a record
36
+ * lists it instead of serving the previous 30 s snapshot.
37
+ */
38
+ export function invalidateOptionsCache() {
39
+ optionsGeneration++;
40
+ optionsCache.clear();
41
+ optionsInflight.clear();
42
+ }
30
43
  export function optionsRequestKey(scope, url, field, query, limit, filter) {
31
44
  return [scope, url, field, query, String(limit ?? ''), filter ?? ''].join('\n');
32
45
  }
@@ -108,6 +121,7 @@ export function useOptionsResolver(args) {
108
121
  optionsInflight.delete(key);
109
122
  }
110
123
  const epoch = optionsEpoch.get(key) ?? 0;
124
+ const generation = optionsGeneration;
111
125
  const cached = optionsCache.get(key);
112
126
  if (cached && Date.now() - cached.at < OPTIONS_TTL_MS) {
113
127
  setOptions(cached.payload.options);
@@ -140,7 +154,7 @@ export function useOptionsResolver(args) {
140
154
  data: part.data,
141
155
  meta: part.meta,
142
156
  });
143
- if ((optionsEpoch.get(key) ?? 0) === epoch) {
157
+ if ((optionsEpoch.get(key) ?? 0) === epoch && optionsGeneration === generation) {
144
158
  optionsCache.set(key, { payload, at: Date.now() });
145
159
  }
146
160
  return payload;
@@ -158,7 +172,7 @@ export function useOptionsResolver(args) {
158
172
  throw new Error(body?.message || 'options resolver: unsuccessful response');
159
173
  }
160
174
  const payload = readOptionsEnvelope(body);
161
- if ((optionsEpoch.get(key) ?? 0) === epoch) {
175
+ if ((optionsEpoch.get(key) ?? 0) === epoch && optionsGeneration === generation) {
162
176
  optionsCache.set(key, { payload, at: Date.now() });
163
177
  }
164
178
  return payload;
@@ -1 +1 @@
1
- {"version":3,"file":"validation-catalog.d.ts","sourceRoot":"","sources":["../src/validation-catalog.ts"],"names":[],"mappings":"AAAA;;yEAEyE;AAEzE,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA2CtE,CAAA;AAED,wBAAgB,iBAAiB,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAG3E;AAED,oFAAoF;AACpF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAI3F"}
1
+ {"version":3,"file":"validation-catalog.d.ts","sourceRoot":"","sources":["../src/validation-catalog.ts"],"names":[],"mappings":"AAAA;;yEAEyE;AAEzE,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA6CtE,CAAA;AAED,wBAAgB,iBAAiB,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAG3E;AAED,oFAAoF;AACpF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAI3F"}
@@ -21,6 +21,7 @@ export const VALIDATION_CATALOGS = {
21
21
  integer: '{{label}} debe ser un entero',
22
22
  custom: '{{label}} no es válido',
23
23
  line_items_required: '{{label}} requiere al menos un renglón',
24
+ protected_field: 'El campo {{label}} lo administra el sistema y no se puede modificar',
24
25
  fallback: '{{label}}: valor inválido',
25
26
  },
26
27
  en: {
@@ -42,6 +43,7 @@ export const VALIDATION_CATALOGS = {
42
43
  integer: 'The {{label}} must be an integer',
43
44
  custom: 'The {{label}} is invalid',
44
45
  line_items_required: '{{label}} requires at least one row',
46
+ protected_field: 'The {{label}} field is managed by the system and cannot be changed',
45
47
  fallback: '{{label}}: invalid value',
46
48
  },
47
49
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "39.2.7",
3
+ "version": "39.2.9",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "peerDependencies": {
29
29
  "@asteby/metacore-sdk": "^3.8.0",
30
- "@asteby/metacore-ui": "^2.18.2",
30
+ "@asteby/metacore-ui": "^2.18.3",
31
31
  "@tanstack/react-query": ">=5",
32
32
  "@tanstack/react-router": ">=1.100",
33
33
  "@tanstack/react-table": ">=8",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "devDependencies": {
53
53
  "@asteby/metacore-sdk": "3.8.0",
54
- "@asteby/metacore-ui": "2.18.2",
54
+ "@asteby/metacore-ui": "2.18.3",
55
55
  "@tanstack/react-query": "^5.95.0",
56
56
  "@tanstack/react-router": "^1.168.0",
57
57
  "@tanstack/react-table": "^8.20.0",
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { es } from 'date-fns/locale'
3
+ import { parseCalendarDate } from '../calendar-date'
4
+ import { formatDateCell } from '../dynamic-columns'
5
+
6
+ describe('parseCalendarDate', () => {
7
+ it('lee UTC medianoche como el mismo día local (PIT-025)', () => {
8
+ const d = parseCalendarDate('2026-09-27T00:00:00Z')!
9
+ expect([d.getFullYear(), d.getMonth(), d.getDate()]).toEqual([2026, 8, 27])
10
+ })
11
+
12
+ it('acepta YYYY-MM-DD y vacíos', () => {
13
+ expect(parseCalendarDate('2026-01-05')!.getDate()).toBe(5)
14
+ expect(parseCalendarDate('')).toBeUndefined()
15
+ expect(parseCalendarDate(null)).toBeUndefined()
16
+ expect(parseCalendarDate('no es fecha')).toBeUndefined()
17
+ })
18
+
19
+ it('la celda date sin zona de la org no retrocede un día', () => {
20
+ expect(formatDateCell('2026-09-27T00:00:00Z', 'date', es)?.display).toContain('27')
21
+ })
22
+ })
@@ -0,0 +1,126 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import React from 'react'
4
+ import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
5
+ import { ApiProvider, useApi } from '../api-context'
6
+ import { useOptionsResolver, invalidateOptionsCache } from '../use-options-resolver'
7
+
8
+ afterEach(() => {
9
+ cleanup()
10
+ invalidateOptionsCache()
11
+ })
12
+
13
+ // A picker opened right after creating a record must list it: the 30 s
14
+ // options cache is dropped by every write, whether it goes through useApi()
15
+ // or straight through the host's axios instance.
16
+
17
+ function Picker() {
18
+ const { options } = useOptionsResolver({ modelKey: '', ref: 'Brand', fieldKey: 'id' })
19
+ return <span data-testid="opts">{options.map((o) => o.label).join(',')}</span>
20
+ }
21
+
22
+ function hostClient(brands: string[]) {
23
+ const handlers: Array<(res: any) => any> = []
24
+ const client = {
25
+ get: vi.fn(async () => ({ data: { success: true, data: brands.map((b) => ({ id: b, label: b })) } })),
26
+ // The batch path is unavailable on this fake host: /api/q 404s.
27
+ post: vi.fn(async (url: string) => {
28
+ if (url === '/q') return { data: { success: false } }
29
+ return { data: { success: true } }
30
+ }),
31
+ put: vi.fn(async () => ({ data: { success: true } })),
32
+ delete: vi.fn(async () => ({ data: { success: true } })),
33
+ interceptors: {
34
+ response: {
35
+ use: (ok: (res: any) => any) => {
36
+ handlers.push(ok)
37
+ return handlers.length - 1
38
+ },
39
+ eject: vi.fn(),
40
+ },
41
+ },
42
+ }
43
+ const hostWrite = async (method: string, url = '/data/brands') => {
44
+ for (const h of handlers) h({ config: { method, url } })
45
+ }
46
+ return { client, hostWrite }
47
+ }
48
+
49
+ describe('options cache invalidation', () => {
50
+ it('a host write through its own client drops the cached options', async () => {
51
+ const brands = ['A']
52
+ const { client, hostWrite } = hostClient(brands)
53
+ const view = render(
54
+ <ApiProvider client={client}>
55
+ <Picker />
56
+ </ApiProvider>,
57
+ )
58
+ await waitFor(() => expect(screen.getByTestId('opts').textContent).toBe('A'))
59
+
60
+ brands.push('B')
61
+ await act(async () => {
62
+ await hostWrite('post')
63
+ })
64
+ view.unmount()
65
+ render(
66
+ <ApiProvider client={client}>
67
+ <Picker />
68
+ </ApiProvider>,
69
+ )
70
+ await waitFor(() => expect(screen.getByTestId('opts').textContent).toBe('A,B'))
71
+ })
72
+
73
+ it('neither a GET nor the /q read batch drops the cache', async () => {
74
+ const brands = ['A']
75
+ const { client, hostWrite } = hostClient(brands)
76
+ const view = render(
77
+ <ApiProvider client={client}>
78
+ <Picker />
79
+ </ApiProvider>,
80
+ )
81
+ await waitFor(() => expect(screen.getByTestId('opts').textContent).toBe('A'))
82
+ const calls = client.get.mock.calls.length
83
+ brands.push('B')
84
+ await act(async () => {
85
+ await hostWrite('get')
86
+ await hostWrite('post', '/q')
87
+ })
88
+ view.unmount()
89
+ render(
90
+ <ApiProvider client={client}>
91
+ <Picker />
92
+ </ApiProvider>,
93
+ )
94
+ await waitFor(() => expect(screen.getByTestId('opts').textContent).toBe('A'))
95
+ expect(client.get.mock.calls.length).toBe(calls)
96
+ })
97
+
98
+ it('a write through useApi() drops the cached options', async () => {
99
+ const brands = ['A']
100
+ const { client } = hostClient(brands)
101
+ let write: (() => Promise<unknown>) | null = null
102
+ function Writer() {
103
+ const api = useApi()
104
+ write = () => api.post('/data/brands', { name: 'B' })
105
+ return null
106
+ }
107
+ const view = render(
108
+ <ApiProvider client={{ ...client, interceptors: undefined } as never}>
109
+ <Picker />
110
+ <Writer />
111
+ </ApiProvider>,
112
+ )
113
+ await waitFor(() => expect(screen.getByTestId('opts').textContent).toBe('A'))
114
+ brands.push('B')
115
+ await act(async () => {
116
+ await write!()
117
+ })
118
+ view.unmount()
119
+ render(
120
+ <ApiProvider client={{ ...client, interceptors: undefined } as never}>
121
+ <Picker />
122
+ </ApiProvider>,
123
+ )
124
+ await waitFor(() => expect(screen.getByTestId('opts').textContent).toBe('A,B'))
125
+ })
126
+ })