@asteby/metacore-runtime-react 39.2.5 → 39.2.7

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.
@@ -12,16 +12,52 @@
12
12
  // free; legacy callers that still ship `searchEndpoint` keep working.
13
13
  import { useEffect, useMemo, useRef, useState } from 'react';
14
14
  import { useApi } from './api-context';
15
+ import { loadQueryPart, optionsBatchToken, optionsModelFromUrl } from './query-batch';
15
16
  /**
16
17
  * Resolves select options for a field via the canonical
17
18
  * `/api/options/:model?field=…` endpoint. Returns the v0.9.0 envelope
18
19
  * `{ data, meta: { type, count } }` projected into a stable shape.
19
20
  *
20
- * The hook is intentionally minimal: it does NOT debounce `query`
21
- * (callers should hold the controlled value and pass it post-debounce)
22
- * and does NOT cache across hook instances (apps that need shared state
23
- * compose this with TanStack Query in their own layer).
21
+ * The hook does NOT debounce `query` (callers should pass it post-debounce).
22
+ * Identical lookups share one in-flight request and a 30s cache, so a column
23
+ * of reference cells does not fan out one request per row. The key includes
24
+ * the org and branch from the host session.
24
25
  */
26
+ const OPTIONS_TTL_MS = 30_000;
27
+ const optionsCache = new Map();
28
+ const optionsInflight = new Map();
29
+ const optionsEpoch = new Map();
30
+ export function optionsRequestKey(scope, url, field, query, limit, filter) {
31
+ return [scope, url, field, query, String(limit ?? ''), filter ?? ''].join('\n');
32
+ }
33
+ function optionsScope() {
34
+ if (typeof localStorage === 'undefined')
35
+ return '';
36
+ let org = '';
37
+ try {
38
+ const raw = localStorage.getItem('auth_user');
39
+ if (raw)
40
+ org = String(JSON.parse(raw).organization_id ?? '');
41
+ }
42
+ catch {
43
+ org = '';
44
+ }
45
+ const branch = localStorage.getItem('active_branch_id') ?? '';
46
+ return `${org}|${branch}`;
47
+ }
48
+ function readOptionsEnvelope(body) {
49
+ const rawOptions = Array.isArray(body?.data) ? body.data : [];
50
+ const metaPayload = body?.meta && typeof body.meta === 'object'
51
+ ? body.meta
52
+ : { type: body?.type, count: rawOptions.length };
53
+ return {
54
+ options: rawOptions.map(projectOption),
55
+ meta: {
56
+ type: metaPayload?.type ?? 'dynamic',
57
+ count: typeof metaPayload?.count === 'number' ? metaPayload.count : rawOptions.length,
58
+ },
59
+ };
60
+ }
25
61
  export function useOptionsResolver(args) {
26
62
  const { modelKey, fieldKey, ref, query, limit, enabled = true, endpoint, filterValue, } = args;
27
63
  const api = useApi();
@@ -55,9 +91,7 @@ export function useOptionsResolver(args) {
55
91
  return 'id';
56
92
  return '';
57
93
  }, [fieldKey, ref]);
58
- // Track the in-flight controller so a new fetch can abort the
59
- // previous one — matters for typeahead callers passing changing `query`.
60
- const abortRef = useRef(null);
94
+ const seenRefresh = useRef(0);
61
95
  useEffect(() => {
62
96
  if (!enabled || !url || !effectiveField) {
63
97
  setOptions([]);
@@ -66,61 +100,95 @@ export function useOptionsResolver(args) {
66
100
  setError(null);
67
101
  return;
68
102
  }
69
- // Cancel any pending request before issuing a new one.
70
- abortRef.current?.abort();
71
- const controller = new AbortController();
72
- abortRef.current = controller;
103
+ const key = optionsRequestKey(optionsScope(), url, effectiveField, query ?? '', limit, filterValue);
104
+ if (refreshKey !== seenRefresh.current) {
105
+ seenRefresh.current = refreshKey;
106
+ optionsEpoch.set(key, (optionsEpoch.get(key) ?? 0) + 1);
107
+ optionsCache.delete(key);
108
+ optionsInflight.delete(key);
109
+ }
110
+ const epoch = optionsEpoch.get(key) ?? 0;
111
+ const cached = optionsCache.get(key);
112
+ if (cached && Date.now() - cached.at < OPTIONS_TTL_MS) {
113
+ setOptions(cached.payload.options);
114
+ setMeta(cached.payload.meta);
115
+ setLoading(false);
116
+ setError(null);
117
+ return;
118
+ }
73
119
  setLoading(true);
74
120
  setError(null);
75
- const params = { field: effectiveField };
76
- if (query)
77
- params.q = query;
78
- if (typeof limit === 'number' && limit > 0)
79
- params.limit = limit;
80
- // Cascade scope: a dependent picker passes the value of the field it
81
- // `dependsOn`. Skip empty strings so a cleared parent omits the param
82
- // (no scope) rather than querying for the empty-string filter_value.
83
- if (filterValue)
84
- params.filter_value = filterValue;
85
- api.get(url, { params, signal: controller.signal })
86
- .then((res) => {
87
- if (controller.signal.aborted)
88
- return;
89
- const body = res.data;
90
- if (!body || body.success !== true) {
91
- throw new Error(body?.message || 'options resolver: unsuccessful response');
92
- }
93
- const rawOptions = Array.isArray(body.data) ? body.data : [];
94
- const projected = rawOptions.map(projectOption);
95
- setOptions(projected);
96
- // v0.9.0 envelope: meta.type / meta.count. We tolerate
97
- // older deployments that still emit a root-level `type`
98
- // by reading either spot — the projection prefers the
99
- // canonical location so the SDK guides apps to the new
100
- // shape without breaking grace-period upgrades.
101
- const metaPayload = body.meta && typeof body.meta === 'object'
102
- ? body.meta
103
- : { type: body.type, count: rawOptions.length };
104
- setMeta({
105
- type: metaPayload?.type ?? 'dynamic',
106
- count: typeof metaPayload?.count === 'number'
107
- ? metaPayload.count
108
- : rawOptions.length,
121
+ let pending = optionsInflight.get(key);
122
+ if (!pending) {
123
+ const params = { field: effectiveField };
124
+ if (query)
125
+ params.q = query;
126
+ if (typeof limit === 'number' && limit > 0)
127
+ params.limit = limit;
128
+ if (filterValue)
129
+ params.filter_value = filterValue;
130
+ const model = optionsModelFromUrl(url);
131
+ pending = (async () => {
132
+ if (model) {
133
+ try {
134
+ const part = await loadQueryPart(api, optionsBatchToken(model, effectiveField, query ?? '', limit, filterValue));
135
+ if (!part.success) {
136
+ throw new Error(part.message || 'options resolver: unsuccessful response');
137
+ }
138
+ const payload = readOptionsEnvelope({
139
+ success: true,
140
+ data: part.data,
141
+ meta: part.meta,
142
+ });
143
+ if ((optionsEpoch.get(key) ?? 0) === epoch) {
144
+ optionsCache.set(key, { payload, at: Date.now() });
145
+ }
146
+ return payload;
147
+ }
148
+ catch (err) {
149
+ const msg = err instanceof Error ? err.message : '';
150
+ const batchMiss = msg === 'batch unavailable' || msg === 'batch part missing';
151
+ if (!batchMiss)
152
+ throw err;
153
+ }
154
+ }
155
+ const res = await api.get(url, { params });
156
+ const body = res.data;
157
+ if (!body || body.success !== true) {
158
+ throw new Error(body?.message || 'options resolver: unsuccessful response');
159
+ }
160
+ const payload = readOptionsEnvelope(body);
161
+ if ((optionsEpoch.get(key) ?? 0) === epoch) {
162
+ optionsCache.set(key, { payload, at: Date.now() });
163
+ }
164
+ return payload;
165
+ })().finally(() => {
166
+ if (optionsInflight.get(key) === pending)
167
+ optionsInflight.delete(key);
109
168
  });
169
+ optionsInflight.set(key, pending);
170
+ }
171
+ let cancelled = false;
172
+ pending
173
+ .then((payload) => {
174
+ if (cancelled)
175
+ return;
176
+ setOptions(payload.options);
177
+ setMeta(payload.meta);
110
178
  })
111
179
  .catch((err) => {
112
- if (controller.signal.aborted)
180
+ if (cancelled)
113
181
  return;
114
182
  setError(err instanceof Error ? err : new Error(String(err)));
115
183
  setOptions([]);
116
184
  setMeta(null);
117
185
  })
118
186
  .finally(() => {
119
- if (!controller.signal.aborted)
187
+ if (!cancelled)
120
188
  setLoading(false);
121
189
  });
122
190
  return () => {
123
- controller.abort();
191
+ cancelled = true;
124
192
  };
125
193
  }, [api, url, effectiveField, query, limit, enabled, filterValue, refreshKey]);
126
194
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "39.2.5",
3
+ "version": "39.2.7",
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.1",
30
+ "@asteby/metacore-ui": "^2.18.2",
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.1",
54
+ "@asteby/metacore-ui": "2.18.2",
55
55
  "@tanstack/react-query": "^5.95.0",
56
56
  "@tanstack/react-router": "^1.168.0",
57
57
  "@tanstack/react-table": "^8.20.0",
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, it, expect, vi } from 'vitest'
2
- import { projectOption } from '../use-options-resolver'
2
+ import { optionsRequestKey, projectOption } from '../use-options-resolver'
3
3
  import {
4
4
  resolveValidatorToken,
5
5
  setOrgConfigBridge,
@@ -11,6 +11,14 @@ import {
11
11
  // hook performs) so consumers can rely on the v0.9.0 envelope reading
12
12
  // without spinning up a renderer.
13
13
 
14
+ describe('optionsRequestKey', () => {
15
+ it('keeps two orgs from sharing a column cache', () => {
16
+ const a = optionsRequestKey('org-a|branch', '/options/Product', 'id', '', 200, undefined)
17
+ const b = optionsRequestKey('org-b|branch', '/options/Product', 'id', '', 200, undefined)
18
+ expect(a).not.toBe(b)
19
+ })
20
+ })
21
+
14
22
  describe('projectOption', () => {
15
23
  it('mirrors id into value and label into name when missing', () => {
16
24
  const out = projectOption({ id: 'abc', label: 'Hello' })
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import * as React from 'react'
14
- import * as icons from 'lucide-react'
14
+ import { Calendar, Check, ExternalLink, Mail, Minus } from 'lucide-react'
15
15
  import { es, enUS } from 'date-fns/locale'
16
16
  import {
17
17
  Badge,
@@ -231,7 +231,7 @@ export const ActivityValueRenderer: React.FC<ActivityValueRendererProps> = ({
231
231
  className="inline-flex items-center gap-1 text-sm text-muted-foreground"
232
232
  title={formatted.title}
233
233
  >
234
- <icons.Calendar className="h-3 w-3 opacity-60" />
234
+ <Calendar className="h-3 w-3 opacity-60" />
235
235
  {formatted.display}
236
236
  </span>
237
237
  )
@@ -245,9 +245,9 @@ export const ActivityValueRenderer: React.FC<ActivityValueRendererProps> = ({
245
245
  return (
246
246
  <span className="inline-flex items-center gap-1">
247
247
  {value ? (
248
- <icons.Check className="h-3.5 w-3.5 text-green-500" />
248
+ <Check className="h-3.5 w-3.5 text-green-500" />
249
249
  ) : (
250
- <icons.Minus className="h-3.5 w-3.5 text-muted-foreground" />
250
+ <Minus className="h-3.5 w-3.5 text-muted-foreground" />
251
251
  )}
252
252
  <span className="text-sm text-muted-foreground">{value ? 'Sí' : 'No'}</span>
253
253
  </span>
@@ -350,7 +350,7 @@ export const ActivityValueRenderer: React.FC<ActivityValueRendererProps> = ({
350
350
  className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
351
351
  onClick={(e) => e.stopPropagation()}
352
352
  >
353
- <icons.ExternalLink className="h-3 w-3 shrink-0" />
353
+ <ExternalLink className="h-3 w-3 shrink-0" />
354
354
  <span className="truncate" style={{ maxWidth: 200 }}>{label}</span>
355
355
  </a>
356
356
  )
@@ -368,7 +368,7 @@ export const ActivityValueRenderer: React.FC<ActivityValueRendererProps> = ({
368
368
  className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
369
369
  onClick={(e) => e.stopPropagation()}
370
370
  >
371
- <icons.Mail className="h-3 w-3 shrink-0" />
371
+ <Mail className="h-3 w-3 shrink-0" />
372
372
  <span className="truncate" style={{ maxWidth: 200 }}>{email}</span>
373
373
  </a>
374
374
  )
@@ -16,8 +16,7 @@ import * as React from 'react'
16
16
  import { ColumnDef } from '@tanstack/react-table'
17
17
  import { format, type Locale } from 'date-fns'
18
18
  import { es, enUS } from 'date-fns/locale'
19
- import * as icons from 'lucide-react'
20
- import { MoreHorizontal } from 'lucide-react'
19
+ import { Calendar, Check, Copy, Mail, Minus, MoreHorizontal } from 'lucide-react'
21
20
  import {
22
21
  Avatar,
23
22
  AvatarFallback,
@@ -198,9 +197,9 @@ const CodeCell: React.FC<{ text: string; maxLength?: number }> = ({ text, maxLen
198
197
  title="Copiar"
199
198
  >
200
199
  {copied ? (
201
- <icons.Check className="h-3.5 w-3.5 text-green-500" />
200
+ <Check className="h-3.5 w-3.5 text-green-500" />
202
201
  ) : (
203
- <icons.Copy className="h-3.5 w-3.5" />
202
+ <Copy className="h-3.5 w-3.5" />
204
203
  )}
205
204
  </button>
206
205
  </div>
@@ -1082,7 +1081,7 @@ export function makeDefaultGetDynamicColumns(
1082
1081
  className="flex items-center gap-1.5 text-muted-foreground"
1083
1082
  title={formatted.title}
1084
1083
  >
1085
- <icons.Calendar className="h-3.5 w-3.5 opacity-70" />
1084
+ <Calendar className="h-3.5 w-3.5 opacity-70" />
1086
1085
  <span className="text-sm font-medium">
1087
1086
  {formatted.display}
1088
1087
  </span>
@@ -1174,7 +1173,7 @@ export function makeDefaultGetDynamicColumns(
1174
1173
  className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
1175
1174
  onClick={(e) => e.stopPropagation()}
1176
1175
  >
1177
- <icons.Mail className="h-3.5 w-3.5 shrink-0 opacity-70" />
1176
+ <Mail className="h-3.5 w-3.5 shrink-0 opacity-70" />
1178
1177
  <span className="truncate max-w-[260px]">{email}</span>
1179
1178
  </a>
1180
1179
  )
@@ -1337,9 +1336,9 @@ export function makeDefaultGetDynamicColumns(
1337
1336
  return (
1338
1337
  <span className="inline-flex items-center gap-1.5">
1339
1338
  {value ? (
1340
- <icons.Check className="h-4 w-4 text-green-500" />
1339
+ <Check className="h-4 w-4 text-green-500" />
1341
1340
  ) : (
1342
- <icons.Minus className="h-4 w-4 text-muted-foreground" />
1341
+ <Minus className="h-4 w-4 text-muted-foreground" />
1343
1342
  )}
1344
1343
  {showText && (
1345
1344
  <span className="text-sm text-muted-foreground">
@@ -1,18 +1,48 @@
1
- // Minimal DynamicIcon — resolves a lucide-react icon by name. Used across
2
- // action modals and the default row-action menus. Hosts that need custom
3
- // icon sets can override by shadowing this component via their own prop.
4
- import * as icons from 'lucide-react'
1
+ // DynamicIcon resolves a lucide glyph by name without pulling the full icon
2
+ // set into the shared chunk. Each glyph is its own dynamic import.
3
+ import { lazy, Suspense, useMemo, type ComponentType } from 'react'
4
+ import dynamicIconImports from 'lucide-react/dynamicIconImports'
5
5
 
6
6
  export interface DynamicIconProps {
7
7
  name: string
8
8
  className?: string
9
9
  }
10
10
 
11
+ type IconName = keyof typeof dynamicIconImports
12
+
13
+ function pascalToKebab(pascal: string): string {
14
+ return pascal
15
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
16
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
17
+ .toLowerCase()
18
+ }
19
+
20
+ function kebabToPascal(kebab: string): string {
21
+ return kebab
22
+ .split('-')
23
+ .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : ''))
24
+ .join('')
25
+ }
26
+
27
+ function loaderFor(pascal: string) {
28
+ const kebab = pascalToKebab(pascal) as IconName
29
+ return dynamicIconImports[kebab]
30
+ }
31
+
11
32
  export function DynamicIcon({ name, className }: DynamicIconProps) {
12
- const resolved = resolveLucideIconName(name) ?? name
13
- const Icon = (icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[resolved]
33
+ const resolved = resolveLucideIconName(name)
34
+ const Icon = useMemo(() => {
35
+ if (!resolved) return null
36
+ const loader = loaderFor(resolved)
37
+ if (!loader) return null
38
+ return lazy(loader as () => Promise<{ default: ComponentType<{ className?: string }> }>)
39
+ }, [resolved])
14
40
  if (!Icon) return null
15
- return <Icon className={className} />
41
+ return (
42
+ <Suspense fallback={null}>
43
+ <Icon className={className} />
44
+ </Suspense>
45
+ )
16
46
  }
17
47
 
18
48
  // resolveLucideIconName — canonical PascalCase lucide name for a value that is
@@ -24,13 +54,10 @@ export function resolveLucideIconName(value: unknown): string | null {
24
54
  if (/[/\\.:\s]/.test(value)) return null
25
55
  let name = value
26
56
  if (/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
27
- name = value
28
- .split('-')
29
- .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
30
- .join('')
57
+ name = kebabToPascal(value)
31
58
  }
32
59
  if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) return null
33
- return (icons as unknown as Record<string, unknown>)[name] ? name : null
60
+ return loaderFor(name) ? name : null
34
61
  }
35
62
 
36
63
  // isLucideIconName — true when a string is a lucide-react icon name
@@ -43,3 +70,8 @@ export function resolveLucideIconName(value: unknown): string | null {
43
70
  export function isLucideIconName(value: unknown): value is string {
44
71
  return resolveLucideIconName(value) !== null
45
72
  }
73
+
74
+ /** PascalCase names of every lucide glyph, for the icon picker grid. */
75
+ export function lucideIconNames(): string[] {
76
+ return Object.keys(dynamicIconImports).map(kebabToPascal)
77
+ }
@@ -12,8 +12,8 @@
12
12
  // initial mode is inferred from the current value — path-like ("/" or ".")
13
13
  // means image, anything else means icon.
14
14
  import { useMemo, useState } from 'react'
15
- import { icons } from 'lucide-react'
16
15
  import { ChevronsUpDown } from 'lucide-react'
16
+ import { lucideIconNames } from './dynamic-icon'
17
17
  import {
18
18
  Button,
19
19
  Input,
@@ -35,7 +35,7 @@ export interface IconPickerFieldProps {
35
35
  * the DOM light: the lucide catalog is ~1500 glyphs, so we never mount them all. */
36
36
  const PAGE = 60
37
37
 
38
- const ALL_ICON_NAMES = Object.keys(icons)
38
+ const ALL_ICON_NAMES = lucideIconNames()
39
39
 
40
40
  /** True when a stored value looks like an image url/path rather than an icon name. */
41
41
  export function looksLikeImageValue(value: unknown): boolean {
package/src/index.ts CHANGED
@@ -392,6 +392,13 @@ export {
392
392
  AUDIT_SUBTABLE_COLUMN_KEYS,
393
393
  getSearchableColumnKeys,
394
394
  } from './column-visibility'
395
+ export {
396
+ loadQueryPart,
397
+ nameBatchTokens,
398
+ optionsBatchToken,
399
+ optionsModelFromUrl,
400
+ type QueryPart,
401
+ } from './query-batch'
395
402
  export {
396
403
  useOptionsResolver,
397
404
  projectOption,
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { nameBatchTokens, optionsBatchToken, optionsModelFromUrl } from './query-batch'
3
+
4
+ describe('query batch tokens', () => {
5
+ it('names distinct tokens and keeps duplicates as one part', () => {
6
+ const named = nameBatchTokens([
7
+ 'o:Customer?field=id',
8
+ 'o:Product?field=id',
9
+ 'o:Customer?field=id',
10
+ ])
11
+ expect(named).toEqual([
12
+ { name: 'q1', token: 'o:Customer?field=id', plan: 'q1=o:Customer?field=id' },
13
+ { name: 'q2', token: 'o:Product?field=id', plan: 'q2=o:Product?field=id' },
14
+ ])
15
+ })
16
+
17
+ it('builds an options token from the canonical url', () => {
18
+ expect(optionsModelFromUrl('/options/Customer')).toBe('Customer')
19
+ expect(optionsModelFromUrl('/data/Customer')).toBeNull()
20
+ expect(optionsBatchToken('Customer', 'id', 'lop', 20, undefined)).toBe(
21
+ 'o:Customer?field=id&q=lop&limit=20',
22
+ )
23
+ })
24
+ })
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Coalesces concurrent reads into one POST /api/q.
3
+ *
4
+ * Callers enqueue a batch token (`o:Customer?field=id&q=lop`). Tokens that
5
+ * arrive inside an 8ms window share one request. Each part keeps its own
6
+ * success, data and etag. A failed part does not reject the others.
7
+ */
8
+ import type { ApiClient } from './api-context'
9
+
10
+ export interface QueryPart {
11
+ success: boolean
12
+ data?: unknown
13
+ meta?: unknown
14
+ message?: string
15
+ etag?: string
16
+ not_modified?: boolean
17
+ }
18
+
19
+ const WINDOW_MS = 8
20
+ const PART_TTL_MS = 30_000
21
+
22
+ type Waiter = {
23
+ token: string
24
+ resolve: (part: QueryPart) => void
25
+ reject: (error: Error) => void
26
+ }
27
+
28
+ type CachedPart = { part: QueryPart; at: number }
29
+
30
+ const partCache = new Map<string, CachedPart>()
31
+ let waiters: Waiter[] = []
32
+ let timer: ReturnType<typeof setTimeout> | null = null
33
+ let transport: ApiClient | null = null
34
+
35
+ function cacheScope(): string {
36
+ if (typeof localStorage === 'undefined') return ''
37
+ try {
38
+ const raw = localStorage.getItem('auth_user')
39
+ if (!raw) return ''
40
+ return String((JSON.parse(raw) as { organization_id?: string }).organization_id ?? '')
41
+ } catch {
42
+ return ''
43
+ }
44
+ }
45
+
46
+ export function optionsModelFromUrl(url: string): string | null {
47
+ const match = url.match(/^\/options\/([A-Za-z][A-Za-z0-9_]*)$/)
48
+ return match ? match[1] : null
49
+ }
50
+
51
+ export function optionsBatchToken(
52
+ model: string,
53
+ field: string,
54
+ query: string,
55
+ limit: number | undefined,
56
+ filter: string | undefined,
57
+ ): string {
58
+ const params = new URLSearchParams()
59
+ params.set('field', field)
60
+ if (query) params.set('q', query)
61
+ if (typeof limit === 'number' && limit > 0) params.set('limit', String(limit))
62
+ if (filter) params.set('filter_value', filter)
63
+ return `o:${model}?${params.toString()}`
64
+ }
65
+
66
+ /** Names each distinct token so the server can key the response. */
67
+ export function nameBatchTokens(tokens: string[]): { name: string; token: string; plan: string }[] {
68
+ const unique: string[] = []
69
+ const seen = new Set<string>()
70
+ for (const token of tokens) {
71
+ if (seen.has(token)) continue
72
+ seen.add(token)
73
+ unique.push(token)
74
+ }
75
+ return unique.map((token, index) => {
76
+ const name = `q${index + 1}`
77
+ return { name, token, plan: `${name}=${token}` }
78
+ })
79
+ }
80
+
81
+ export function loadQueryPart(api: ApiClient, token: string): Promise<QueryPart> {
82
+ const cached = partCache.get(`${cacheScope()}\n${token}`)
83
+ if (cached && Date.now() - cached.at < PART_TTL_MS && cached.part.success) {
84
+ return Promise.resolve(cached.part)
85
+ }
86
+ return new Promise((resolve, reject) => {
87
+ transport = api
88
+ waiters.push({ token, resolve, reject })
89
+ if (timer == null) timer = setTimeout(() => void flushQueryBatch(), WINDOW_MS)
90
+ })
91
+ }
92
+
93
+ async function flushQueryBatch() {
94
+ timer = null
95
+ const batch = waiters
96
+ waiters = []
97
+ const api = transport
98
+ if (!api || batch.length === 0) return
99
+ const named = nameBatchTokens(batch.map((waiter) => waiter.token))
100
+ const byToken = new Map(named.map((part) => [part.token, part.name]))
101
+ const inm: Record<string, string> = {}
102
+ const scope = cacheScope()
103
+ for (const part of named) {
104
+ const cached = partCache.get(`${scope}\n${part.token}`)
105
+ if (cached?.part.etag) inm[part.name] = cached.part.etag
106
+ }
107
+ try {
108
+ const res = await api.post('/q', { parts: named.map((part) => part.plan), inm })
109
+ const body = res?.data
110
+ if (!body || body.success !== true || !body.parts) {
111
+ throw new Error('batch unavailable')
112
+ }
113
+ for (const waiter of batch) {
114
+ const name = byToken.get(waiter.token)
115
+ const part = name ? (body.parts[name] as QueryPart | undefined) : undefined
116
+ if (!part) {
117
+ waiter.reject(new Error('batch part missing'))
118
+ continue
119
+ }
120
+ if (part.not_modified) {
121
+ const cached = partCache.get(`${scope}\n${waiter.token}`)
122
+ if (cached) {
123
+ waiter.resolve(cached.part)
124
+ continue
125
+ }
126
+ }
127
+ if (part.success) {
128
+ partCache.set(`${scope}\n${waiter.token}`, { part, at: Date.now() })
129
+ }
130
+ waiter.resolve(part)
131
+ }
132
+ } catch (err) {
133
+ const error = err instanceof Error ? err : new Error(String(err))
134
+ for (const waiter of batch) waiter.reject(error)
135
+ }
136
+ }