@everystack/admin 0.4.5 → 0.4.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +22 -5
  3. package/src/adapters/CannotSortError.ts +19 -0
  4. package/src/adapters/EverystackAdapter.ts +33 -20
  5. package/src/adapters/EverystackApiError.ts +18 -0
  6. package/src/adapters/types.ts +13 -4
  7. package/src/components/DateConditionInput.tsx +148 -0
  8. package/src/components/DateFilterBar.tsx +85 -0
  9. package/src/components/DateTimeValue.tsx +37 -0
  10. package/src/components/DeletePreview.tsx +15 -6
  11. package/src/hooks/useAdminData.ts +44 -3
  12. package/src/hooks/useDateRangeFilters.ts +47 -0
  13. package/src/hooks/useViewState.ts +41 -8
  14. package/src/index.ts +16 -0
  15. package/src/providers/AdminConfigProvider.tsx +11 -2
  16. package/src/providers/DataAdapterProvider.tsx +5 -3
  17. package/src/rpc/list-filter.ts +210 -0
  18. package/src/rpc/resource-dashboard-rpc.ts +62 -13
  19. package/src/screens/AdminRouter.tsx +19 -6
  20. package/src/screens/ResourceCreate.tsx +51 -6
  21. package/src/screens/ResourceEdit.tsx +71 -8
  22. package/src/screens/ResourceList.tsx +118 -17
  23. package/src/screens/ResourceListView.tsx +7 -3
  24. package/src/screens/ResourceShow.tsx +14 -9
  25. package/src/types.ts +114 -11
  26. package/src/utils/changed-fields.ts +54 -0
  27. package/src/utils/date-filter-fields.ts +32 -0
  28. package/src/utils/date-range.ts +146 -0
  29. package/src/utils/datetime-submission.ts +25 -0
  30. package/src/utils/field-sortable.ts +19 -0
  31. package/src/utils/format-cell-value.ts +28 -5
  32. package/src/utils/integrity-error.ts +16 -0
  33. package/src/utils/list-query.ts +3 -3
  34. package/src/utils/resource-query.ts +303 -0
  35. package/src/utils/validate-config.ts +85 -0
  36. package/src/utils/view-filters.ts +9 -3
  37. package/src/utils/wall-clock.ts +72 -0
@@ -10,11 +10,13 @@ import { Button } from '@everystack/ui';
10
10
  import type { DataAdapter } from '../adapters/types';
11
11
  import type { AssociationConfig } from '../types';
12
12
  import { safeRelationId } from '../hooks/useRelationLabels';
13
+ import { useRawDataAdapter } from '../hooks/useAdminData';
13
14
 
14
15
  /** Each association costs one request, so the preview counts at most this many. */
15
16
  export const MAX_ASSOCIATIONS = 12;
16
17
 
17
18
  export interface DeletePreviewProps {
19
+ /** The screen's adapter (useDataAdapter), for associations named by `resource`. */
18
20
  adapter: DataAdapter;
19
21
  /** e.g. 'Contents' */
20
22
  label: string;
@@ -27,7 +29,11 @@ export interface DeletePreviewProps {
27
29
  onCancel: () => void;
28
30
  }
29
31
 
30
- /** The linked rows of one association: a `count` aggregate when the adapter has one, else a one-row list's total. */
32
+ /**
33
+ * The linked rows of one association: a `count` aggregate when the adapter has one, else a one-row
34
+ * list's total. It reads `table ?? resource` on the adapter it is given; the caller picks that
35
+ * adapter (the raw one for `table`, so the name is never read as a resource).
36
+ */
31
37
  export async function countAssociation(adapter: DataAdapter, association: AssociationConfig, id: string | number): Promise<number> {
32
38
  const safeId = safeRelationId(id);
33
39
  if (safeId === null) throw new Error('Association count needs a safe record id');
@@ -35,15 +41,16 @@ export async function countAssociation(adapter: DataAdapter, association: Associ
35
41
  if (association.typeColumn && association.typeValue == null) {
36
42
  throw new Error(`association "${association.label}": typeColumn needs a typeValue`);
37
43
  }
44
+ const related = association.table ?? association.resource;
38
45
  const filter = {
39
46
  [association.foreignKey]: `eq.${safeId}`,
40
47
  ...(association.typeColumn ? { [association.typeColumn]: association.typeValue } : {}),
41
48
  };
42
49
  if (adapter.aggregate) {
43
- const result = await adapter.aggregate(association.resource, { aggregates: [{ function: 'count' }], filter });
50
+ const result = await adapter.aggregate(related, { aggregates: [{ function: 'count' }], filter });
44
51
  return Number(result.data[0]?.count ?? 0);
45
52
  }
46
- return (await adapter.fetchList(association.resource, { page: 1, perPage: 1, filter })).total;
53
+ return (await adapter.fetchList(related, { page: 1, perPage: 1, filter })).total;
47
54
  }
48
55
 
49
56
  type Count = number | '?' | undefined;
@@ -61,6 +68,8 @@ export function DeletePreview({
61
68
  const [counts, setCounts] = useState<Count[]>([]);
62
69
  const busy = useRef(false);
63
70
  const [error, setError] = useState<string | null>(null);
71
+ // An association's `table` is a real table: count it on the adapter without the resource mapping.
72
+ const raw = useRawDataAdapter();
64
73
 
65
74
  useEffect(() => {
66
75
  if (associations.length > MAX_ASSOCIATIONS) {
@@ -68,12 +77,12 @@ export function DeletePreview({
68
77
  }
69
78
  let live = true;
70
79
  setCounts([]);
71
- Promise.allSettled(associations.slice(0, MAX_ASSOCIATIONS).map(a => countAssociation(adapter, a, id)))
80
+ Promise.allSettled(associations.slice(0, MAX_ASSOCIATIONS).map(a => countAssociation(a.table !== undefined ? raw : adapter, a, id)))
72
81
  .then(results => {
73
82
  if (live) setCounts(results.map(r => (r.status === 'fulfilled' ? r.value : '?')));
74
83
  });
75
84
  return () => { live = false; };
76
- }, [adapter, associations, id, label]);
85
+ }, [adapter, raw, associations, id, label]);
77
86
 
78
87
  const confirm = async (): Promise<void> => {
79
88
  if (busy.current) return;
@@ -92,7 +101,7 @@ export function DeletePreview({
92
101
  <View testID="delete-preview" className="mx-4 mb-4 p-3 rounded-md bg-red-50">
93
102
  <Text className="text-base font-semibold text-red-800 mb-2">{`Delete ${label} #${String(id)}?`}</Text>
94
103
  {shown.map((association, i) => (
95
- <Text key={`${association.resource}:${association.foreignKey}:${i}`} className="text-sm text-gray-900">
104
+ <Text key={`${association.table ?? association.resource}:${association.foreignKey}:${i}`} className="text-sm text-gray-900">
96
105
  {`${association.label}: ${counts[i] ?? '…'}`}
97
106
  </Text>
98
107
  ))}
@@ -4,9 +4,11 @@
4
4
  * Provides data fetching capabilities using the configured DataAdapter.
5
5
  */
6
6
 
7
- import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
7
+ import { createContext, useContext, useMemo, useState, useEffect, useCallback, useRef } from 'react';
8
8
  import type { DataAdapter } from '../adapters/types';
9
9
  import type { ListParams, DataRecord } from '../types';
10
+ import { AdminConfigContext } from './useAdminConfig';
11
+ import { resourceQuerySignature, withResourceQuery } from '../utils/resource-query';
10
12
 
11
13
  // =============================================================================
12
14
  // Context
@@ -18,12 +20,41 @@ export interface DataAdapterContextValue {
18
20
 
19
21
  export const DataAdapterContext = createContext<DataAdapterContextValue | null>(null);
20
22
 
23
+ /**
24
+ * The adapter every screen calls, mapped to each resource's `table` and narrowed to its `where`.
25
+ * The wrapping is done here, where both contexts are read, so the order the providers are nested
26
+ * in cannot leave a resource's `table` or `where` quietly unapplied.
27
+ */
21
28
  export function useDataAdapter(): DataAdapter {
22
29
  const context = useContext(DataAdapterContext);
23
- if (!context) {
30
+ const config = useContext(AdminConfigContext)?.config;
31
+ const adapter = context?.adapter;
32
+ // Keyed on what the wrapper reads, not on the config object: an inline config literal is a new
33
+ // object every render, and a new wrapper would refetch every hook keyed on the adapter.
34
+ // The memo runs with this render's config whenever the signature changes, and the signature
35
+ // covers every part of the config the wrapper reads, so `config` itself is not a dependency.
36
+ const signature = config ? resourceQuerySignature(config) : null;
37
+ const queryAdapter = useMemo(
38
+ () => (adapter && config ? withResourceQuery(adapter, config) : adapter),
39
+ // eslint-disable-next-line react-hooks/exhaustive-deps
40
+ [adapter, signature],
41
+ );
42
+ if (!queryAdapter) {
24
43
  throw new Error('useDataAdapter must be used within a DataAdapterProvider');
25
44
  }
26
- return context.adapter;
45
+ return queryAdapter;
46
+ }
47
+
48
+ /**
49
+ * The adapter as the DataAdapterProvider holds it, without the `table` and `where` mapping. For a
50
+ * call that names a real table, never a resource (a delete-preview association's `table`).
51
+ */
52
+ export function useRawDataAdapter(): DataAdapter {
53
+ const adapter = useContext(DataAdapterContext)?.adapter;
54
+ if (!adapter) {
55
+ throw new Error('useRawDataAdapter must be used within a DataAdapterProvider');
56
+ }
57
+ return adapter;
27
58
  }
28
59
 
29
60
  // =============================================================================
@@ -146,6 +177,16 @@ export function useAdminData<T extends DataRecord = DataRecord>(
146
177
  const byRefetch = refetchIndex !== lastRefetchIndexRef.current;
147
178
  lastRefetchIndexRef.current = refetchIndex;
148
179
  if (options.skip) {
180
+ // A skipped query holds no result: a response in flight must not land, and rows or an
181
+ // error from the last query must not show once the query it belonged to is gone.
182
+ ++seqRef.current;
183
+ setData(rows => (rows.length === 0 ? rows : []));
184
+ setTotal(0);
185
+ setError(null);
186
+ shownParamsRef.current = null;
187
+ setLoading(false);
188
+ setRefreshing(false);
189
+ settle(carriedRef.current.splice(0));
149
190
  settle(queuedRef.current.splice(0));
150
191
  return;
151
192
  }
@@ -0,0 +1,47 @@
1
+ import { useMemo } from 'react';
2
+ import { parseOwnedRange, rangeFilter, type DateRangeFilter } from '../utils/date-range';
3
+
4
+ export interface DateRangeRow {
5
+ field: string;
6
+ status: 'off' | 'ready' | 'invalid';
7
+ pair?: DateRangeFilter;
8
+ reason?: string;
9
+ }
10
+
11
+ export interface UseDateRangeFiltersResult {
12
+ rows: DateRangeRow[];
13
+ /** The ready rows' bounds, merged: one `gte`/`lt` pair per field. */
14
+ filter?: Record<string, string[]>;
15
+ /** True while any row is invalid: the list fetches nothing. */
16
+ block: boolean;
17
+ }
18
+
19
+ /** Validate each field's owned URL conditions independently of the display zone. */
20
+ export function useDateRangeFilters(
21
+ fields: readonly string[],
22
+ valuesOf: (field: string) => readonly string[],
23
+ ): UseDateRangeFiltersResult {
24
+ const key = JSON.stringify([...new Set(fields)].map(field => [field, valuesOf(field)]));
25
+ return useMemo((): UseDateRangeFiltersResult => {
26
+ const rows: DateRangeRow[] = [];
27
+ const bounds: Array<[string, string[]]> = [];
28
+ for (const [field, values] of JSON.parse(key) as Array<[string, string[]]>) {
29
+ const parsed = parseOwnedRange(values);
30
+ if (parsed.kind === 'absent') {
31
+ rows.push({ field, status: 'off' });
32
+ } else if (parsed.kind === 'invalid') {
33
+ rows.push({ field, status: 'invalid', reason: parsed.reason });
34
+ } else {
35
+ const pair = { gte: parsed.gte, lt: parsed.lt };
36
+ rows.push({ field, status: 'ready', pair });
37
+ bounds.push(...Object.entries(rangeFilter(field, pair)));
38
+ }
39
+ }
40
+ return {
41
+ rows,
42
+ // fromEntries defines own properties, so a column named `__proto__` is not a prototype write.
43
+ ...(bounds.length > 0 ? { filter: Object.fromEntries(bounds) } : {}),
44
+ block: rows.some(row => row.status === 'invalid'),
45
+ };
46
+ }, [key]);
47
+ }
@@ -12,13 +12,22 @@ import { createContext, useContext } from 'react';
12
12
  /** Current query params, normalized to single strings (first value wins). */
13
13
  export type ViewParams = Record<string, string>;
14
14
 
15
+ /** Every nonempty query value for each key, in URL order. */
16
+ export type ViewParamValues = Record<string, string[]>;
17
+
18
+ /** A delta replaces all values of a key; undefined or an empty array removes it. */
19
+ export type ViewParamDelta = Record<string, string | string[] | undefined>;
20
+
15
21
  export interface ViewStateContextValue {
16
22
  params: ViewParams;
23
+ /** All values per key; optional for contexts supplied by existing embedders. */
24
+ paramValues?: ViewParamValues;
17
25
  /**
18
26
  * Merge `next` into the current params and navigate. Keys set to undefined are
19
- * removed from the URL. The path is preserved; only the query changes.
27
+ * removed from the URL. An array replaces all values; an empty array removes
28
+ * the key. The path is preserved; only the query changes.
20
29
  */
21
- setParams: (next: Record<string, string | undefined>) => void;
30
+ setParams: (next: ViewParamDelta) => void;
22
31
  }
23
32
 
24
33
  export const ViewStateContext = createContext<ViewStateContextValue | null>(null);
@@ -28,9 +37,17 @@ export function useViewState(): ViewStateContextValue {
28
37
  return useContext(ViewStateContext) ?? { params: {}, setParams: () => {} };
29
38
  }
30
39
 
40
+ /** Read all values when available, falling back to the first-value context. */
41
+ export function valuesOf(view: Pick<ViewStateContextValue, 'params' | 'paramValues'>, key: string): string[] {
42
+ const own = (record: object | undefined, name: string): boolean => record !== undefined && Object.prototype.hasOwnProperty.call(record, name);
43
+ const values = own(view.paramValues, key) ? view.paramValues![key]
44
+ : own(view.params, key) && view.params[key] !== undefined ? [view.params[key]] : [];
45
+ return values.filter(value => value !== '');
46
+ }
47
+
31
48
  /** Normalize a raw searchParams object (values may be arrays) to single strings. */
32
49
  export function normalizeParams(raw?: Record<string, string | string[] | undefined>): ViewParams {
33
- const out: ViewParams = {};
50
+ const out: ViewParams = Object.create(null);
34
51
  if (!raw) return out;
35
52
  for (const [k, v] of Object.entries(raw)) {
36
53
  if (v == null) continue;
@@ -39,12 +56,28 @@ export function normalizeParams(raw?: Record<string, string | string[] | undefin
39
56
  return out;
40
57
  }
41
58
 
42
- /** Build a `?a=1&b=2` query string from params; empty → ''. Undefined dropped. */
43
- export function buildQueryString(params: Record<string, string | undefined>): string {
59
+ /** Keep every nonempty value; an empty key has [] so keys match normalizeParams. */
60
+ export function normalizeParamValues(
61
+ raw?: Record<string, string | (string | null | undefined)[] | null | undefined>,
62
+ ): ViewParamValues {
63
+ const out: ViewParamValues = Object.create(null);
64
+ if (!raw) return out;
65
+ for (const [key, value] of Object.entries(raw)) {
66
+ if (value == null) continue;
67
+ const values = Array.isArray(value) ? value : [value];
68
+ out[key] = values.filter((v): v is string => v != null && v !== '');
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** Build a query string, appending each value. Empty and undefined values are dropped. */
74
+ export function buildQueryString(params: ViewParamDelta): string {
44
75
  const usp = new URLSearchParams();
45
- for (const [k, v] of Object.entries(params)) {
46
- if (v == null || v === '') continue;
47
- usp.set(k, v);
76
+ for (const [key, value] of Object.entries(params)) {
77
+ for (const v of Array.isArray(value) ? value : [value]) {
78
+ if (v == null || v === '') continue;
79
+ usp.append(key, v);
80
+ }
48
81
  }
49
82
  const s = usp.toString();
50
83
  return s ? `?${s}` : '';
package/src/index.ts CHANGED
@@ -10,6 +10,10 @@
10
10
  export type {
11
11
  FieldType,
12
12
  FieldDefinition,
13
+ FieldDefinitionBase,
14
+ WallClockDatetime,
15
+ InstantDatetime,
16
+ NoTimeZone,
13
17
  TagGroup,
14
18
  ImageSize,
15
19
  CellContext,
@@ -20,6 +24,8 @@ export type {
20
24
  EditorComponent,
21
25
  EditorRegistry,
22
26
  ResourceConfig,
27
+ DateRangeConfig,
28
+ DateRangePreset,
23
29
  ScreenLayout,
24
30
  LayoutColumn,
25
31
  StorageConfig,
@@ -33,6 +39,7 @@ export type {
33
39
  ScopeConfig,
34
40
  AssociationConfig,
35
41
  DeletedMode,
42
+ WhereValues,
36
43
  ListResult,
37
44
  DataRecord,
38
45
  AggregateFunction,
@@ -56,9 +63,12 @@ export type {
56
63
  } from './adapters/types';
57
64
 
58
65
  // EverystackAdapter
66
+ export { CannotSortError, isCannotSortError } from './adapters/CannotSortError';
67
+ export { EverystackApiError } from './adapters/EverystackApiError';
59
68
  export { EverystackAdapter, type EverystackAdapterOptions } from './adapters/EverystackAdapter';
60
69
 
61
70
  // Hooks
71
+ export { useDateRangeFilters, type UseDateRangeFiltersResult, type DateRangeRow } from './hooks/useDateRangeFilters';
62
72
  export { useAdminConfig, type AdminConfigContextValue } from './hooks/useAdminConfig';
63
73
  export {
64
74
  useDataAdapter,
@@ -87,8 +97,12 @@ export {
87
97
  useViewState,
88
98
  ViewStateContext,
89
99
  normalizeParams,
100
+ normalizeParamValues,
101
+ valuesOf,
90
102
  buildQueryString,
91
103
  type ViewParams,
104
+ type ViewParamValues,
105
+ type ViewParamDelta,
92
106
  type ViewStateContextValue,
93
107
  } from './hooks/useViewState';
94
108
 
@@ -174,6 +188,7 @@ export { ImageDisplay } from './components/ImageDisplay';
174
188
  export { RowActionButton, type RowActionButtonProps } from './components/RowActionButton';
175
189
  export { AdminLink, type AdminLinkProps } from './components/AdminLink';
176
190
  export { FormActionBar, type FormActionBarProps } from './components/FormActionBar';
191
+ export { DateConditionInput, type DateConditionInputProps } from './components/DateConditionInput';
177
192
 
178
193
  // Image utilities
179
194
  export { resolveImageUrl, type ResolveImageUrlOptions } from './utils/resolve-image-url';
@@ -184,6 +199,7 @@ export { visibleRowActions } from './utils/row-actions';
184
199
  export { enumScopes } from './utils/scopes';
185
200
 
186
201
  // Utilities
202
+ export { isFieldSortable } from './utils/field-sortable';
187
203
  export {
188
204
  toCamelCase,
189
205
  toSnakeCase,
@@ -4,9 +4,11 @@
4
4
  * Provides AdminConfig to the admin component tree.
5
5
  */
6
6
 
7
- import React from 'react';
7
+ import React, { useMemo } from 'react';
8
+ import { TimeZoneProvider } from '@everystack/ui';
8
9
  import type { AdminConfig } from '../types';
9
10
  import { AdminConfigContext } from '../hooks/useAdminConfig';
11
+ import { validateAdminConfig } from '../utils/validate-config';
10
12
 
11
13
  export interface AdminConfigProviderProps {
12
14
  config: AdminConfig;
@@ -14,5 +16,12 @@ export interface AdminConfigProviderProps {
14
16
  }
15
17
 
16
18
  export function AdminConfigProvider({ config, children }: AdminConfigProviderProps) {
17
- return <AdminConfigContext.Provider value={{ config }}>{children}</AdminConfigContext.Provider>;
19
+ // Setup is where a bad config fails: loudly, naming the resource and field, not later on a
20
+ // screen that happens to read it.
21
+ useMemo(() => validateAdminConfig(config), [config]);
22
+ return (
23
+ <AdminConfigContext.Provider value={{ config }}>
24
+ <TimeZoneProvider timeZone={config.timeZone}>{children}</TimeZoneProvider>
25
+ </AdminConfigContext.Provider>
26
+ );
18
27
  }
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * DataAdapterProvider
3
3
  *
4
- * Provides the DataAdapter to the admin component tree.
4
+ * Provides the DataAdapter to the admin component tree. useDataAdapter() hands screens this adapter
5
+ * mapped to the config's `table` and `where` (withResourceQuery), whichever way the providers are nested.
5
6
  */
6
7
 
7
- import React from 'react';
8
+ import React, { useMemo } from 'react';
8
9
  import type { DataAdapter } from '../adapters/types';
9
10
  import { DataAdapterContext } from '../hooks/useAdminData';
10
11
 
@@ -14,5 +15,6 @@ export interface DataAdapterProviderProps {
14
15
  }
15
16
 
16
17
  export function DataAdapterProvider({ adapter, children }: DataAdapterProviderProps) {
17
- return <DataAdapterContext.Provider value={{ adapter }}>{children}</DataAdapterContext.Provider>;
18
+ const value = useMemo(() => ({ adapter }), [adapter]);
19
+ return <DataAdapterContext.Provider value={value}>{children}</DataAdapterContext.Provider>;
18
20
  }
@@ -0,0 +1,210 @@
1
+ import { sql, type SQL } from 'drizzle-orm';
2
+ import { HandlerError } from '@everystack/api/errors';
3
+ import { toCamelCase } from '../utils/naming';
4
+ import type { ResourceDashboardConfig } from '../types';
5
+
6
+ // Keep recognition in step with EverystackAdapter's POSTGREST_OPERATORS. Recognized
7
+ // but unsupported operators must refuse, rather than turn into plain equality.
8
+ const KNOWN_OPERATORS = new Set([
9
+ 'eq', 'neq', 'gt', 'gte', 'lt', 'lte',
10
+ 'like', 'ilike', 'is', 'in', 'not', 'cs', 'cd',
11
+ 'sl', 'sr', 'nxl', 'nxr', 'adj', 'ov', 'fts',
12
+ ]);
13
+ const OPERATORS = new Map([
14
+ ['eq', '='], ['neq', '<>'], ['gt', '>'], ['gte', '>='], ['lt', '<'], ['lte', '<='],
15
+ ]);
16
+ const ERROR_COLUMN = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
17
+ const UTC_INSTANT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,9})?Z$/;
18
+ const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
19
+ const MAX_MEMBERS = 25;
20
+ const MAX_MEMBER_LENGTH = 256;
21
+ const MAX_MEMBERSHIP_LENGTH = MAX_MEMBERS * MAX_MEMBER_LENGTH + MAX_MEMBERS - 1 + 2;
22
+
23
+ interface FilterColumns {
24
+ exact: Set<string>;
25
+ search: Set<string>;
26
+ dimensions: Set<string>;
27
+ aliases: Map<string, string | null>;
28
+ allowedValues: Map<string, Set<string>>;
29
+ }
30
+
31
+ const columnsByConfig = new WeakMap<ResourceDashboardConfig, FilterColumns>();
32
+
33
+ function filterColumns(config: ResourceDashboardConfig): FilterColumns {
34
+ const cached = columnsByConfig.get(config);
35
+ if (cached) return cached;
36
+ const exact = new Set([
37
+ ...config.listColumns, config.timestampColumn, ...config.dimensions.map(dim => dim.column),
38
+ ]);
39
+ const search = new Set(config.searchColumns ?? []);
40
+ const dimensions = new Set(config.dimensions.map(dim => dim.column));
41
+ const aliases = new Map<string, string | null>();
42
+ for (const column of new Set([...exact, ...search])) {
43
+ const alias = toCamelCase(column);
44
+ aliases.set(alias, aliases.has(alias) ? null : column);
45
+ }
46
+ const allowedValues = new Map<string, Set<string>>();
47
+ for (const dim of config.dimensions) {
48
+ if (dim.allowedValues?.length) allowedValues.set(dim.column, new Set(dim.allowedValues));
49
+ }
50
+ const result = { exact, search, dimensions, aliases, allowedValues };
51
+ columnsByConfig.set(config, result);
52
+ return result;
53
+ }
54
+
55
+ function refuse(message: string, column?: string): never {
56
+ // Never pass an unsafe caller name to HandlerError's metadata constructor.
57
+ throw new HandlerError(400, message, {
58
+ code: 'invalid_filter',
59
+ ...(column !== undefined && ERROR_COLUMN.exec(column)?.[0] === column ? { column } : {}),
60
+ });
61
+ }
62
+
63
+ function isUtcInstant(value: string): boolean {
64
+ const match = UTC_INSTANT.exec(value);
65
+ if (!match) return false;
66
+ // PostgreSQL has no year 0000; refuse it here rather than let the database answer 422.
67
+ if (Number(match[1]) < 1) return false;
68
+ const [, year, month, day, hour, minute, second] = match;
69
+ const parts = [year, month, day, hour, minute, second].map(Number);
70
+ const [y, m, d, h, min, s] = parts;
71
+ // Date.UTC treats years 0..99 as 1900..1999. A 400-year shift preserves leap
72
+ // years, then setUTCFullYear restores the requested year without that coercion.
73
+ const date = new Date(Date.UTC(y < 100 ? y + 400 : y, m - 1, d, h, min, s));
74
+ if (y < 100) date.setUTCFullYear(y);
75
+ return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 &&
76
+ date.getUTCDate() === d && date.getUTCHours() === h &&
77
+ date.getUTCMinutes() === min && date.getUTCSeconds() === s;
78
+ }
79
+
80
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
81
+ if (value === null || typeof value !== 'object') return false;
82
+ const prototype = Object.getPrototypeOf(value);
83
+ if (prototype === null) return true;
84
+ // Request.json() can return another realm's Object.prototype (also in Jest).
85
+ const constructor = Object.getOwnPropertyDescriptor(prototype, 'constructor')?.value;
86
+ return Object.getPrototypeOf(prototype) === null && typeof constructor === 'function' &&
87
+ Function.prototype.toString.call(constructor) === Function.prototype.toString.call(Object);
88
+ }
89
+
90
+ function isWellFormed(value: string): boolean {
91
+ const check = (String.prototype as typeof String.prototype & {
92
+ isWellFormed?: (this: string) => boolean;
93
+ }).isWellFormed;
94
+ return check ? check.call(value) : !LONE_SURROGATE.test(value);
95
+ }
96
+
97
+ /** Translate the adapter's `*` pattern to SQL LIKE with `ESCAPE '!'`. */
98
+ export function ilikePattern(raw: string): string | null {
99
+ if (raw.length > 256 || raw.includes('\0') || !isWellFormed(raw)) return null;
100
+ let pattern = '';
101
+ let wildcards = 0;
102
+ for (let i = 0; i < raw.length; i++) {
103
+ let char = raw[i];
104
+ if (char === '\\') {
105
+ if (i + 1 < raw.length) char = raw[++i];
106
+ } else if (char === '*') {
107
+ if (++wildcards > 8) return null;
108
+ pattern += '%';
109
+ continue;
110
+ }
111
+ pattern += char === '!' || char === '%' || char === '_' ? `!${char}` : char;
112
+ }
113
+ return pattern;
114
+ }
115
+
116
+ function parseCondition(raw: unknown, column: string): { operator: string; value: string } {
117
+ // The adapter sends a number or boolean as its String() text; mirror that, and refuse the rest.
118
+ const text = typeof raw === 'string' ? raw
119
+ : typeof raw === 'boolean' || (typeof raw === 'number' && Number.isFinite(raw)) ? String(raw)
120
+ : undefined;
121
+ if (text === undefined) refuse('Filter values must be strings, numbers or booleans', column);
122
+ if (text.includes('\0')) refuse('Invalid filter value', column);
123
+ // Same rule as the adapter: only an exact known-operator prefix is an operator. Any other
124
+ // dotted text (`example.com`, `cs2.log`, `gte;.x`) is a plain value, bound as a parameter.
125
+ let operator = 'eq';
126
+ let value = text;
127
+ const dot = text.indexOf('.');
128
+ if (dot > 0) {
129
+ const prefix = text.slice(0, dot);
130
+ if (KNOWN_OPERATORS.has(prefix)) {
131
+ if (!OPERATORS.has(prefix) && prefix !== 'ilike' && prefix !== 'in') refuse('Unsupported filter operator', column);
132
+ operator = prefix;
133
+ value = text.slice(dot + 1);
134
+ }
135
+ }
136
+ if (value.length > (operator === 'in' ? MAX_MEMBERSHIP_LENGTH : 256)) refuse('Filter value is too long', column);
137
+ if (!isWellFormed(value)) refuse('Invalid filter value', column);
138
+ return { operator, value };
139
+ }
140
+
141
+ function membershipValues(value: string, column: string): string[] {
142
+ if (!value.startsWith('(') || !value.endsWith(')')) refuse('Invalid filter membership', column);
143
+ const body = value.slice(1, -1);
144
+ // The native mapper emits simple comma lists. Refuse quoted, escaped or nested
145
+ // forms rather than interpret a quoted comma as two different members.
146
+ if (/[()"\\]/.test(body)) refuse('Invalid filter membership', column);
147
+ // parseCondition bounds the entire list before this allocation.
148
+ const members = body.split(',');
149
+ if (members.length > MAX_MEMBERS || members.some(member => member.length === 0 || member.length > MAX_MEMBER_LENGTH)) {
150
+ refuse('Invalid filter membership', column);
151
+ }
152
+ return members;
153
+ }
154
+
155
+ /** Compile list-protocol conditions using only factory-validated config identifiers. */
156
+ export function compileListFilter(
157
+ config: ResourceDashboardConfig,
158
+ filter: unknown,
159
+ ): { conditions: SQL[]; timeBounded: boolean } {
160
+ const columns = filterColumns(config);
161
+ const conditions: SQL[] = [];
162
+ let timeBounded = false;
163
+ if (filter == null) return { conditions, timeBounded };
164
+ if (!isPlainObject(filter)) {
165
+ refuse('Filter must be a plain object');
166
+ }
167
+ const countByColumn = new Map<string, number>();
168
+ for (const [name, raw] of Object.entries(filter)) {
169
+ const column = columns.exact.has(name) || columns.search.has(name) ? name : columns.aliases.get(name);
170
+ if (column === null) refuse('Ambiguous filter column', name);
171
+ if (column === undefined) refuse('Unknown filter column', name);
172
+ const values = Array.isArray(raw) ? raw : [raw];
173
+ if (values.length === 0) refuse('Filter conditions must not be empty', name);
174
+ const count = (countByColumn.get(column) ?? 0) + values.length;
175
+ if (count > 10 || conditions.length + values.length > 50) refuse('Too many filter conditions', name);
176
+ countByColumn.set(column, count);
177
+ for (const rawValue of values) {
178
+ if (rawValue == null) continue; // the adapter skips null and undefined
179
+ const { operator, value } = parseCondition(rawValue, name);
180
+ if (operator === 'in') {
181
+ if (!columns.dimensions.has(column)) refuse('Column does not support membership filters', name);
182
+ const members = membershipValues(value, name);
183
+ const allowed = columns.allowedValues.get(column);
184
+ if (allowed && members.some(member => !allowed.has(member))) refuse('Invalid filter dimension value', name);
185
+ if (column === config.timestampColumn && members.some(member => !isUtcInstant(member))) {
186
+ refuse('Filter timestamp must be a valid UTC instant', name);
187
+ }
188
+ conditions.push(sql`${sql.raw(column)} IN (${sql.join(members.map(member => sql`${member}`), sql`, `)})`);
189
+ continue; // Membership does not replace the default time window.
190
+ }
191
+ if (operator === 'ilike') {
192
+ if (!columns.search.has(column)) refuse('Column does not support ilike filters', name);
193
+ const pattern = ilikePattern(value);
194
+ if (pattern === null) refuse('Invalid filter pattern', name);
195
+ conditions.push(sql`${sql.raw(column)} ILIKE ${pattern} ESCAPE '!'`);
196
+ continue;
197
+ }
198
+ if (!columns.exact.has(column)) refuse('Unknown filter column', name);
199
+ if (column === config.timestampColumn && !isUtcInstant(value)) refuse('Filter timestamp must be a valid UTC instant', name);
200
+ const allowed = columns.allowedValues.get(column);
201
+ if ((operator === 'eq' || operator === 'neq') && allowed && !allowed.has(value)) {
202
+ refuse('Invalid filter dimension value', name);
203
+ }
204
+ // No cast: PostgreSQL infers the parameter type from the configured column.
205
+ conditions.push(sql`${sql.raw(column)} ${sql.raw(OPERATORS.get(operator)!)} ${value}`);
206
+ if (column === config.timestampColumn && operator !== 'neq') timeBounded = true;
207
+ }
208
+ }
209
+ return { conditions, timeBounded };
210
+ }