@everystack/admin 0.4.6 → 0.4.8

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.
@@ -0,0 +1,146 @@
1
+ import { dayRangeBounds, instantToParts, parseDay, parseInstant } from '@everystack/ui/date-time';
2
+ import { assertIanaTimeZone } from '@everystack/ui/time-zone';
3
+ import type { DateRangeConfig, DateRangePreset } from '../types';
4
+
5
+ export interface DateRange {
6
+ from: string;
7
+ to: string;
8
+ }
9
+
10
+ export interface DateRangeFilter {
11
+ gte: string;
12
+ lt: string;
13
+ }
14
+
15
+ export type CompiledDateRange =
16
+ | { kind: 'ok'; filter: DateRangeFilter }
17
+ | { kind: 'invalid'; reason: string };
18
+
19
+ /** Inclusive calendar days become an inclusive start and an exclusive end in UTC. */
20
+ export function compileDateRange(range: DateRange, timeZone: string): CompiledDateRange {
21
+ try {
22
+ assertIanaTimeZone(timeZone);
23
+ const bounds = dayRangeBounds(range.from, range.to, timeZone);
24
+ return { kind: 'ok', filter: { gte: bounds.from, lt: bounds.until } };
25
+ } catch (error) {
26
+ if (error instanceof RangeError) return { kind: 'invalid', reason: error.message };
27
+ throw error;
28
+ }
29
+ }
30
+
31
+ /** The adapter ANDs the two conditions on the configured field. */
32
+ export function rangeFilter(field: string, bounds: DateRangeFilter): Record<string, string[]> {
33
+ return { [field]: [`gte.${bounds.gte}`, `lt.${bounds.lt}`] };
34
+ }
35
+
36
+ /** The caller supplies today in its display zone; arithmetic stays on calendar days. */
37
+ export function presetRange(
38
+ preset: DateRangePreset,
39
+ todayInZone: string,
40
+ weekStartsOn: NonNullable<DateRangeConfig['weekStartsOn']> = 1,
41
+ ): DateRange {
42
+ const today = parseDay(todayInZone);
43
+ if (!today) throw new RangeError('Today must be a valid YYYY-MM-DD day.');
44
+ let from = today;
45
+ let to = today;
46
+ switch (preset) {
47
+ case 'today': break;
48
+ case 'yesterday': from = to = today.subtract({ days: 1 }); break;
49
+ case 'last7Days': from = today.subtract({ days: 6 }); break;
50
+ case 'last30Days': from = today.subtract({ days: 29 }); break;
51
+ case 'thisWeek':
52
+ from = today.subtract({ days: (today.dayOfWeek - weekStartsOn + 7) % 7 });
53
+ break;
54
+ case 'lastWeek': {
55
+ const start = today.subtract({ days: (today.dayOfWeek - weekStartsOn + 7) % 7 });
56
+ from = start.subtract({ days: 7 });
57
+ to = start.subtract({ days: 1 });
58
+ break;
59
+ }
60
+ case 'thisMonth': from = today.with({ day: 1 }); break;
61
+ case 'lastMonth':
62
+ to = today.with({ day: 1 }).subtract({ days: 1 });
63
+ from = to.with({ day: 1 });
64
+ break;
65
+ }
66
+ return { from: from.toString(), to: to.toString() };
67
+ }
68
+
69
+ export type OwnedRange =
70
+ | { kind: 'absent' }
71
+ | ({ kind: 'pair' } & DateRangeFilter)
72
+ | { kind: 'invalid'; reason: string };
73
+
74
+ export type DateRangeConditions =
75
+ | { kind: 'ok'; values: string[] }
76
+ | { kind: 'invalid'; reason: string };
77
+
78
+ const STRICT_INSTANT = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/;
79
+
80
+ function isStrictInstant(value: string): boolean {
81
+ // A year-0000 instant is refused because PostgreSQL has no year 0.
82
+ if (!STRICT_INSTANT.test(value) || value.startsWith('0000-')) return false;
83
+ const instant = new Date(value);
84
+ return Number.isFinite(instant.getTime()) && instant.toISOString() === value;
85
+ }
86
+
87
+ /** Validate all conditions on the owned field without a display zone or a clock. */
88
+ export function parseOwnedRange(values: readonly string[]): OwnedRange {
89
+ if (values.length === 0) return { kind: 'absent' };
90
+ const invalidPair: OwnedRange = {
91
+ kind: 'invalid', reason: 'A date range requires exactly one gte condition and one lt condition.',
92
+ };
93
+ if (values.length !== 2) return invalidPair;
94
+ let gte: string | undefined;
95
+ let lt: string | undefined;
96
+ for (const value of values) {
97
+ if (typeof value !== 'string') return invalidPair;
98
+ if (value.startsWith('gte.') && gte === undefined) gte = value.slice(4);
99
+ else if (value.startsWith('lt.') && lt === undefined) lt = value.slice(3);
100
+ else return invalidPair;
101
+ }
102
+ if (gte === undefined || lt === undefined) return invalidPair;
103
+ if (!isStrictInstant(gte) || !isStrictInstant(lt)) {
104
+ return { kind: 'invalid', reason: 'Date range bounds must be valid UTC instants in YYYY-MM-DDTHH:mm:ss.sssZ format.' };
105
+ }
106
+ if (gte >= lt) return { kind: 'invalid', reason: 'The gte bound must be before the lt bound.' };
107
+ return { kind: 'pair', gte, lt };
108
+ }
109
+
110
+ function instantDay(value: string, timeZone: string): string | null {
111
+ const parts = instantToParts(value, timeZone);
112
+ if (!parts) return null;
113
+ return `${String(parts.year).padStart(4, '0')}-${String(parts.month + 1).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`;
114
+ }
115
+
116
+ /** Only exact calendar-day intervals project to days; invalid zones return null. */
117
+ export function instantsToDays(pair: DateRangeFilter, timeZone: string): DateRange | null {
118
+ try {
119
+ assertIanaTimeZone(timeZone);
120
+ const end = parseInstant(pair.lt)?.epochMilliseconds;
121
+ if (end === undefined) return null;
122
+ const from = instantDay(pair.gte, timeZone);
123
+ // The exclusive end can follow a skipped day. Inspect the last included instant.
124
+ const to = instantDay(new Date(end - 1).toISOString(), timeZone);
125
+ if (!from || !to || from > to) return null;
126
+ const range = { from, to };
127
+ const compiled = compileDateRange(range, timeZone);
128
+ if (compiled.kind !== 'ok' || compiled.filter.gte !== pair.gte || compiled.filter.lt !== pair.lt) return null;
129
+ return range;
130
+ } catch (error) {
131
+ if (error instanceof RangeError) return null;
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ /** Values for the field's repeated URL keys, using the existing calendar-day compiler. */
137
+ export function daysToConditions(field: string, range: DateRange, timeZone: string): DateRangeConditions {
138
+ const compiled = compileDateRange(range, timeZone);
139
+ if (compiled.kind !== 'ok') return compiled;
140
+ // At the calendar extremes the compiler can emit year 0000 or +010000; the URL format cannot
141
+ // carry those, so refuse here instead of writing a URL that parseOwnedRange would reject.
142
+ if (!isStrictInstant(compiled.filter.gte) || !isStrictInstant(compiled.filter.lt)) {
143
+ return { kind: 'invalid', reason: 'That date range is outside the supported years 0001 to 9999.' };
144
+ }
145
+ return { kind: 'ok', values: rangeFilter(field, compiled.filter)[field] };
146
+ }
@@ -0,0 +1,25 @@
1
+ import { parseInstant } from '@everystack/ui/date-time';
2
+ import type { FieldDefinition } from '../types';
3
+ import { toDateInputValue } from './format-cell-value';
4
+
5
+ /** Normalize only values the form will send. Untouched edit fields never enter this body. */
6
+ export function normalizeDateTimeSubmission(
7
+ fields: readonly FieldDefinition[],
8
+ values: Record<string, unknown>,
9
+ ): Record<string, unknown> {
10
+ const body = { ...values };
11
+ for (const field of fields) {
12
+ if (field.type !== 'datetime' || field.storage === 'wall' || !(field.name in body)) continue;
13
+ const value = body[field.name];
14
+ if (value == null || value === '') {
15
+ body[field.name] = null;
16
+ continue;
17
+ }
18
+ const raw = toDateInputValue(value);
19
+ const instant = parseInstant(raw);
20
+ if (!instant) throw new RangeError(`${field.label ?? field.name} is not a valid date and time.`);
21
+ // A picker already emits Z. Keep its precision; Temporal retains nanoseconds for other inputs.
22
+ body[field.name] = raw.endsWith('Z') ? raw.replace(' ', 'T') : instant.toString();
23
+ }
24
+ return body;
25
+ }
@@ -0,0 +1,19 @@
1
+ import type { FieldDefinition } from '../types';
2
+
3
+ /** Whether this field can send a list order to the adapter. */
4
+ export function isFieldSortable(field: FieldDefinition): boolean {
5
+ if (field.virtual === true) return false;
6
+ if (field.sortable === false) return false;
7
+ if (field.sortable === true) return true;
8
+ if (field.format === 'html' || field.format === 'markdown') return false;
9
+ switch (field.type) {
10
+ case 'json':
11
+ case 'tags':
12
+ case 'file':
13
+ case 'image':
14
+ case 'relation':
15
+ return false;
16
+ default:
17
+ return true;
18
+ }
19
+ }
@@ -6,6 +6,7 @@
6
6
  * through untouched — never hide data behind "Invalid Date".
7
7
  */
8
8
 
9
+ import { parseInstant } from '@everystack/ui/date-time';
9
10
  import type { FieldDefinition } from '../types';
10
11
  import { tagLabel, tagValues } from './tags';
11
12
  import { formatWallClock, isWallClock } from './wall-clock';
@@ -51,23 +52,35 @@ export function toDateInputValue(value: unknown): string {
51
52
  return value == null ? '' : String(value);
52
53
  }
53
54
 
54
- /**
55
- * A datetime for display, on the list and the show page alike. A wall-clock field shows its
56
- * stored digits and its zone, whatever the viewer's zone; any other datetime is an instant shown
57
- * in the viewer's local time.
58
- */
59
- export function formatDateTime(field: FieldDefinition, value: unknown): string {
55
+ /** A wall field retains literal digits; instants require an explicitly resolved display zone. */
56
+ export function formatDateTime(field: FieldDefinition, value: unknown, timeZone?: string): string {
60
57
  if (isWallClock(field)) return formatWallClock(field, value);
61
- const date = new Date(String(value));
62
- return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
58
+ const instant = parseInstant(value instanceof Date && !Number.isNaN(value.getTime()) ? value.toISOString() : value);
59
+ if (!instant) return String(value);
60
+ if (!timeZone) return 'Resolving time zone…';
61
+ const display = new Intl.DateTimeFormat('en-US', {
62
+ timeZone, year: 'numeric', month: 'short', day: 'numeric',
63
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', timeZoneName: 'short',
64
+ }).format(new Date(instant.epochMilliseconds));
65
+ // The short name (PDT, GMT+9) sits beside the stamp; the zone's full name is not repeated in every cell.
66
+ return display;
67
+ }
68
+
69
+ /** Exact stored wall carrier or canonical UTC instant, including sub-millisecond precision. */
70
+ export function dateTimeRawValue(field: FieldDefinition, value: unknown): string | null {
71
+ if (value == null) return null;
72
+ const raw = value instanceof Date && !Number.isNaN(value.getTime()) ? value.toISOString() : String(value);
73
+ if (isWallClock(field)) return `Stored digits: ${raw}`;
74
+ const instant = parseInstant(raw);
75
+ return instant ? `UTC: ${instant.toString()}` : `Raw value: ${raw}`;
63
76
  }
64
77
 
65
- export function formatCellValue(field: FieldDefinition, value: unknown): string {
78
+ export function formatCellValue(field: FieldDefinition, value: unknown, timeZone?: string): string {
66
79
  if (value === null || value === undefined) return '—';
67
80
 
68
81
  switch (field.type) {
69
82
  case 'datetime':
70
- return formatDateTime(field, value);
83
+ return formatDateTime(field, value, timeZone);
71
84
  case 'date':
72
85
  return formatDateOnly(value);
73
86
  case 'boolean':
@@ -125,15 +125,15 @@ export function mergeFilters(
125
125
  a?: Record<string, unknown>,
126
126
  b?: Record<string, unknown>,
127
127
  ): Record<string, unknown> | undefined {
128
- const merged: Record<string, unknown> = {};
128
+ const merged = new Map<string, unknown>();
129
129
  for (const source of [a, b]) {
130
130
  if (!source) continue;
131
131
  for (const [key, value] of Object.entries(source)) {
132
132
  if (value == null) continue;
133
- merged[key] = key in merged ? [...toArray(merged[key]), ...toArray(value)] : value;
133
+ merged.set(key, merged.has(key) ? [...toArray(merged.get(key)), ...toArray(value)] : value);
134
134
  }
135
135
  }
136
- return Object.keys(merged).length ? merged : undefined;
136
+ return merged.size ? Object.fromEntries(merged) : undefined;
137
137
  }
138
138
 
139
139
  const toArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : [value]);
@@ -0,0 +1,318 @@
1
+ /**
2
+ * `ResourceConfig.table` and `ResourceConfig.where`: which table a resource reads and writes, and
3
+ * which slice of it. Two resources may share a table, each with its own `where` (single-table
4
+ * inheritance). withResourceQuery() applies both once, on the adapter the providers hand to every
5
+ * screen; everything above the adapter (URL, nav, relations, scopes) keeps the resource name.
6
+ *
7
+ * `where` is a view filter and a forced default. It is not authorization. Any client with the same
8
+ * token can address any row through the API. For a boundary, use a role and an RLS policy (or a
9
+ * view).
10
+ *
11
+ * The name is SQL's: the option is an equality WHERE on the resource's table. Rails' `where(type:
12
+ * 'Adventure').new` presets the same attribute on a new record, and so does this on a create.
13
+ *
14
+ * It keeps staff from mistakes (a wrong type typed into a form, an id typed into the wrong URL):
15
+ * every adapter call for a resource with `where` is narrowed to its slice, and every write carries
16
+ * the `where` values.
17
+ */
18
+
19
+ import type { DataAdapter, SubscriptionEvent } from '../adapters/types';
20
+ import type {
21
+ AdminConfig,
22
+ AggregateParams,
23
+ AggregateResult,
24
+ DataRecord,
25
+ FieldDefinition,
26
+ ListParams,
27
+ ListResult,
28
+ ResourceConfig,
29
+ WhereValues,
30
+ } from '../types';
31
+
32
+ type Filter = Record<string, unknown>;
33
+
34
+ /**
35
+ * The caller's filter with `where` ANDed in: each `where` key is replaced by an exact match and
36
+ * moved last, so `where` wins over a user or tab filter on the same key. `eq.` comes first, so the
37
+ * rest of the value is matched literally, operator-like text, dots, commas and parentheses included.
38
+ */
39
+ export function whereFilter(filter: Filter | undefined, where: WhereValues): Filter {
40
+ const out: Filter = { ...filter };
41
+ for (const [key, value] of Object.entries(where)) {
42
+ delete out[key];
43
+ out[key] = `eq.${String(value)}`;
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * `params` carrying the resource name, for a resource whose `table` is not its name. The name is
50
+ * set last, so a caller cannot override it. For any other resource `params` is returned as is, so
51
+ * an adapter sees no new key.
52
+ */
53
+ function named<P extends object>(resource: string, q: ResourceQuery, params: P): P {
54
+ return q.table === resource ? params : { ...params, resource };
55
+ }
56
+
57
+ /** What the wrapper does for one resource: the table it names, its `where`, if any, and its id column. */
58
+ interface ResourceQuery {
59
+ table: string;
60
+ where?: WhereValues;
61
+ primaryKey: string;
62
+ }
63
+
64
+ /**
65
+ * Keyed by RESOURCE name, never by table: two resources on one table keep their own `where`, and a
66
+ * resource with no entry here passes through as is, even when its name is another resource's table.
67
+ */
68
+ function queriesOf(config: AdminConfig): Map<string, ResourceQuery> {
69
+ const queries = new Map<string, ResourceQuery>();
70
+ for (const resource of config.resources ?? []) {
71
+ const table = resource.table ?? resource.name;
72
+ const where = resource.where && Object.keys(resource.where).length > 0 ? resource.where : undefined;
73
+ if (table !== resource.name || where) queries.set(resource.name, { table, where, primaryKey: resource.primaryKey ?? 'id' });
74
+ }
75
+ return queries;
76
+ }
77
+
78
+ /**
79
+ * A stable text form of everything withResourceQuery() reads from the config. Two configs with the
80
+ * same signature give wrappers that behave the same, so a caller can reuse one wrapper for both.
81
+ */
82
+ export function resourceQuerySignature(config: AdminConfig): string {
83
+ return JSON.stringify([...queriesOf(config)].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
84
+ }
85
+
86
+ const NUMERIC_TEXT = /^-?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/;
87
+
88
+ /**
89
+ * True when a returned value matches the `where` value the way the API returns it. A number
90
+ * compares numerically, so numeric(n,2) text "2.00" matches 2. A boolean accepts true or "true" or
91
+ * "t" (false or "false" or "f"). Anything else is exact text: case-sensitive, no trimming.
92
+ */
93
+ function sameAsWhere(value: unknown, expected: WhereValues[string]): boolean {
94
+ if (typeof expected === 'number') {
95
+ if (typeof value === 'number') return value === expected;
96
+ if (typeof value === 'string' && NUMERIC_TEXT.test(value)) return Number(value) === expected;
97
+ }
98
+ if (typeof expected === 'boolean') {
99
+ return expected ? value === true || value === 'true' || value === 't' : value === false || value === 'false' || value === 'f';
100
+ }
101
+ return String(value) === String(expected);
102
+ }
103
+
104
+ /** `{ type: "Adventure", tier: 2 }`, for an error message. */
105
+ function describeWhere(where: WhereValues): string {
106
+ return `{ ${Object.entries(where).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')} }`;
107
+ }
108
+
109
+ /**
110
+ * A write body that names a DIFFERENT value for a `where` column is not silently taken as if it
111
+ * applied: the `where` value is written (the body's is ignored) and, outside production, one warning
112
+ * names every column that disagreed. The admin's own forms never send these columns, so this fires
113
+ * for an adapter or custom code that passes the full record back.
114
+ */
115
+ function warnOverridden(resource: string, data: unknown, where: WhereValues): void {
116
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'production') return;
117
+ if (data === null || typeof data !== 'object') return;
118
+ const body = data as Record<string, unknown>;
119
+ const clashes = Object.entries(where).filter(([key, expected]) =>
120
+ Object.prototype.hasOwnProperty.call(body, key) && body[key] !== undefined && !sameAsWhere(body[key], expected));
121
+ if (clashes.length === 0) return;
122
+ const named = clashes.map(([key, expected]) => `${key}: the body has ${JSON.stringify(body[key])}, where forces ${JSON.stringify(expected)}`).join('; ');
123
+ // eslint-disable-next-line no-console
124
+ console.warn(`Resource "${resource}": the write names a value that disagrees with its where, so the where value was written and the body's was ignored (${named}).`);
125
+ }
126
+
127
+ /**
128
+ * Throws when a row the adapter returned is outside the resource's `where`. Only keys present in
129
+ * the row are compared: a hidden column or a narrow select leaves a key out, and that is no error.
130
+ * The row is returned as is. `saved` says the write already happened.
131
+ */
132
+ function checkRow<T>(resource: string, q: ResourceQuery, where: WhereValues, row: T, saved: boolean): T {
133
+ if (row === null || typeof row !== 'object') return row;
134
+ const record = row as Record<string, unknown>;
135
+ for (const [key, expected] of Object.entries(where)) {
136
+ if (!Object.prototype.hasOwnProperty.call(record, key) || record[key] === undefined) continue;
137
+ if (sameAsWhere(record[key], expected)) continue;
138
+ const id = record[q.primaryKey];
139
+ const which = id === undefined || id === null ? '' : ` (id ${String(id)})`;
140
+ const rest = `${key} ${JSON.stringify(record[key])}, outside its where ${describeWhere(where)}.`;
141
+ throw new Error(saved
142
+ ? `Resource "${resource}": the row was saved${which} but has ${rest}`
143
+ : `Resource "${resource}": the row${which} has ${rest}`);
144
+ }
145
+ return row;
146
+ }
147
+
148
+ /**
149
+ * The adapter, with every call for a resource that sets `table` or `where` rewritten on the way in.
150
+ * The resource name becomes its table for fetchList, aggregate, fetchOne, create, update, delete and
151
+ * subscribe. When that table is not the name, the call also carries the resource name as
152
+ * `params.resource` (a third argument on create), so an adapter that serves two resources on one
153
+ * table can tell them apart. subscribe does not carry it: its events are delivered under the
154
+ * resource name already. `where` narrows the call to its slice: lists, counts and aggregates get it as a filter;
155
+ * fetchOne, update and delete get it as `params.filter` beside the id, in the one request they send;
156
+ * create and update bodies get the `where` values over the form's. The row that create, update or
157
+ * fetchOne returns is checked against `where`, and one outside it throws, naming it. subscribe
158
+ * drops events whose record is outside `where` and delivers the rest under the resource name.
159
+ * Everything else (rpc,
160
+ * supportsSearch, an adapter's own members) passes through, called on the adapter itself. With no
161
+ * `table` or `where` anywhere, the adapter is returned as is.
162
+ */
163
+ export function withResourceQuery(adapter: DataAdapter, config: AdminConfig): DataAdapter {
164
+ const queries = queriesOf(config);
165
+ if (queries.size === 0) return adapter;
166
+
167
+ type FetchOneParams = Parameters<DataAdapter['fetchOne']>[2];
168
+ type UpdateParams = Parameters<DataAdapter['update']>[3];
169
+ type DeleteParams = Parameters<DataAdapter['delete']>[2];
170
+
171
+ const overrides: Partial<DataAdapter> = {
172
+ fetchList<T extends DataRecord = DataRecord>(resource: string, params: ListParams): Promise<ListResult<T>> {
173
+ const q = queries.get(resource);
174
+ if (!q) return adapter.fetchList<T>(resource, params);
175
+ return adapter.fetchList<T>(q.table, named(resource, q, q.where ? { ...params, filter: whereFilter(params.filter, q.where) } : params));
176
+ },
177
+ aggregate(resource: string, params: AggregateParams): Promise<AggregateResult> {
178
+ const q = queries.get(resource);
179
+ if (!q) return adapter.aggregate!(resource, params);
180
+ return adapter.aggregate!(q.table, named(resource, q, q.where ? { ...params, filter: whereFilter(params.filter, q.where) } : params));
181
+ },
182
+ fetchOne<T extends DataRecord = DataRecord>(resource: string, id: string | number, ...rest: [params?: FetchOneParams]): Promise<T> {
183
+ const q = queries.get(resource);
184
+ if (!q) return adapter.fetchOne<T>(resource, id, ...rest);
185
+ const [params] = rest;
186
+ // No where means the table differs from the name (queriesOf), so the name is always sent.
187
+ if (!q.where) return adapter.fetchOne<T>(q.table, id, named(resource, q, { ...params }));
188
+ const where = q.where;
189
+ return adapter
190
+ .fetchOne<T>(q.table, id, named(resource, q, { ...params, filter: whereFilter(params?.filter, where) }))
191
+ .then(row => checkRow(resource, q, where, row, false));
192
+ },
193
+ create<T extends DataRecord = DataRecord>(resource: string, data: Omit<T, 'id'>): Promise<T> {
194
+ const q = queries.get(resource);
195
+ if (!q) return adapter.create<T>(resource, data);
196
+ // A third argument only when the table is not the name: every other create keeps its two.
197
+ const rest: [params?: { resource?: string }] = q.table === resource ? [] : [{ resource }];
198
+ if (!q.where) return adapter.create<T>(q.table, data, ...rest);
199
+ const where = q.where;
200
+ warnOverridden(resource, data, where);
201
+ return adapter.create<T>(q.table, { ...data, ...where }, ...rest).then(row => checkRow(resource, q, where, row, true));
202
+ },
203
+ update<T extends DataRecord = DataRecord>(
204
+ resource: string,
205
+ id: string | number,
206
+ data: Partial<T>,
207
+ ...rest: [params?: UpdateParams]
208
+ ): Promise<T> {
209
+ const q = queries.get(resource);
210
+ if (!q) return adapter.update<T>(resource, id, data, ...rest);
211
+ const [params] = rest;
212
+ if (!q.where) return adapter.update<T>(q.table, id, data, named(resource, q, { ...params }));
213
+ const where = q.where;
214
+ warnOverridden(resource, data, where);
215
+ return adapter
216
+ .update<T>(q.table, id, { ...data, ...where } as Partial<T>, named(resource, q, { ...params, filter: whereFilter(params?.filter, where) }))
217
+ .then(row => checkRow(resource, q, where, row, true));
218
+ },
219
+ delete(resource: string, id: string | number, ...rest: [params?: DeleteParams]): Promise<void> {
220
+ const q = queries.get(resource);
221
+ if (!q) return adapter.delete(resource, id, ...rest);
222
+ const [params] = rest;
223
+ if (!q.where) return adapter.delete(q.table, id, named(resource, q, { ...params }));
224
+ return adapter.delete(q.table, id, named(resource, q, { ...params, filter: whereFilter(params?.filter, q.where) }));
225
+ },
226
+ subscribe<T extends DataRecord = DataRecord>(resource: string, callback: (event: SubscriptionEvent<T>) => void): () => void {
227
+ const q = queries.get(resource);
228
+ if (!q) return adapter.subscribe!<T>(resource, callback);
229
+ const where = q.where;
230
+ return adapter.subscribe!<T>(q.table, event => {
231
+ // An update that moves a row out of the slice still reaches the list, by its previousRecord.
232
+ if (where && !inSlice(event.record, where) && !(isObject(event.previousRecord) && inSlice(event.previousRecord, where))) return;
233
+ callback({ ...event, resource });
234
+ });
235
+ },
236
+ };
237
+
238
+ // One bound function per original, so a member read twice is the same function (stable deps).
239
+ const bound = new WeakMap<object, unknown>();
240
+ return new Proxy(adapter, {
241
+ get(target, prop) {
242
+ const value: unknown = Reflect.get(target, prop, target);
243
+ if (typeof value !== 'function') return value;
244
+ if (Object.prototype.hasOwnProperty.call(overrides, prop)) return overrides[prop as keyof DataAdapter];
245
+ let fn = bound.get(value);
246
+ if (!fn) {
247
+ fn = (value as (...args: unknown[]) => unknown).bind(target);
248
+ bound.set(value, fn);
249
+ }
250
+ return fn;
251
+ },
252
+ });
253
+ }
254
+
255
+ /**
256
+ * False only when the record has a `where` key with another value. No record, or a record without
257
+ * the key, passes: the slice cannot be told, and dropping it would hide a change.
258
+ */
259
+ function inSlice(record: unknown, where: WhereValues): boolean {
260
+ if (!isObject(record)) return true;
261
+ const row = record as Record<string, unknown>;
262
+ return Object.entries(where).every(([key, expected]) =>
263
+ !Object.prototype.hasOwnProperty.call(row, key) || row[key] === undefined || sameAsWhere(row[key], expected));
264
+ }
265
+
266
+ function isObject(value: unknown): value is object {
267
+ return value !== null && typeof value === 'object';
268
+ }
269
+
270
+ /** The form's fields without the `where` columns: Create and Edit render no input for them. */
271
+ export function withoutWhere(fields: FieldDefinition[], resource: ResourceConfig): FieldDefinition[] {
272
+ const where = resource.where;
273
+ return where ? fields.filter(f => !Object.prototype.hasOwnProperty.call(where, f.name)) : fields;
274
+ }
275
+
276
+ /** Checks every resource's `table` at setup, loudly: absent, or a non-empty string. Two resources may share one. */
277
+ export function validateTable(config: AdminConfig): void {
278
+ for (const resource of config.resources ?? []) {
279
+ const table: unknown = resource.table;
280
+ if (table === undefined) continue;
281
+ if (typeof table !== 'string' || table.trim() === '') {
282
+ throw new TypeError(`Admin resource "${resource.name}", table: must be a non-empty string, got ${JSON.stringify(table)}`);
283
+ }
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Checks every resource's `where` at setup, loudly: an object whose keys each name a field of the
289
+ * resource and whose values are a string, a finite number or a boolean. Config can arrive untyped.
290
+ */
291
+ export function validateWhere(config: AdminConfig): void {
292
+ for (const resource of config.resources ?? []) {
293
+ const where: unknown = resource.where;
294
+ if (where === undefined) continue;
295
+ const at = `Admin resource "${resource.name}", where`;
296
+ if (where === null || typeof where !== 'object' || Array.isArray(where)) {
297
+ throw new TypeError(`${at}: must be an object of column → value, got ${JSON.stringify(where)}`);
298
+ }
299
+ const names = new Set((resource.fields ?? []).map(f => f.name));
300
+ for (const [key, value] of Object.entries(where)) {
301
+ if (!names.has(key)) {
302
+ throw new TypeError(`${at} "${key}": the resource has no field "${key}" (spell it like the field name)`);
303
+ }
304
+ // Both of these would look fine and do nothing: `where` overwrites the pick, and the tab shows the same slice.
305
+ if ((resource.fields ?? []).some(f => f.name === key && f.inlineEdit)) {
306
+ throw new TypeError(`${at} "${key}": the column is in where, so inlineEdit on it would be overwritten by where. Remove inlineEdit.`);
307
+ }
308
+ const scope = (resource.scopes ?? []).find(s => s.filter && Object.prototype.hasOwnProperty.call(s.filter, key));
309
+ if (scope) {
310
+ throw new TypeError(`${at} "${key}": scope "${scope.id}" filters the where column, so where overrides it. Remove that filter or the where entry.`);
311
+ }
312
+ const ok = typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value));
313
+ if (!ok) {
314
+ throw new TypeError(`${at} "${key}": the value must be a string, a finite number or a boolean, got ${typeof value === 'number' || value === undefined ? String(value) : JSON.stringify(value)}`);
315
+ }
316
+ }
317
+ }
318
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Setup is where a bad config fails: loudly, naming the resource and the field or key, not later on
3
+ * a screen that happens to read it. AdminConfigProvider runs this once per config.
4
+ */
5
+
6
+ import { assertIanaTimeZone } from '@everystack/ui/time-zone';
7
+ import type { AdminConfig } from '../types';
8
+ import { validateTable, validateWhere } from './resource-query';
9
+ import { validateWallClockFields } from './wall-clock';
10
+ import { RESERVED_PARAMS } from './view-filters';
11
+ import { isDashboardDimension } from './date-filter-fields';
12
+
13
+ export function validateAdminConfig(config: AdminConfig): void {
14
+ validateResourceNames(config);
15
+ validateWallClockFields(config);
16
+ validateDateConfig(config);
17
+ validateTable(config);
18
+ validateWhere(config);
19
+ validateAssociations(config);
20
+ }
21
+
22
+ /**
23
+ * An association names its related rows by exactly one of `resource` (read through that resource's
24
+ * `table` and `where`) or `table` (the real table, as is). Both, or neither, would count the wrong rows.
25
+ */
26
+ function validateAssociations(config: AdminConfig): void {
27
+ for (const resource of config.resources ?? []) {
28
+ for (const association of resource.associations ?? []) {
29
+ const at = `Admin resource "${resource.name}", association "${association.label}"`;
30
+ const named = (['resource', 'table'] as const).filter(key => association[key] !== undefined);
31
+ if (named.length !== 1) {
32
+ throw new TypeError(`${at}: set exactly one of resource or table, got ${named.length === 0 ? 'neither' : 'both'}`);
33
+ }
34
+ const value: unknown = association[named[0]];
35
+ if (typeof value !== 'string' || value.trim() === '') {
36
+ throw new TypeError(`${at}: ${named[0]} must be a non-empty string, got ${JSON.stringify(value)}`);
37
+ }
38
+ }
39
+ }
40
+ }
41
+
42
+ /** The resource name keys the URL, nav, relations, scopes and `where`, so it must be unique. A table may be shared. */
43
+ function validateResourceNames(config: AdminConfig): void {
44
+ const seen = new Set<string>();
45
+ for (const resource of config.resources ?? []) {
46
+ if (seen.has(resource.name)) {
47
+ throw new TypeError(
48
+ `Admin resource "${resource.name}": the name is used by more than one resource. Give each its own name; two resources may share a table.`,
49
+ );
50
+ }
51
+ seen.add(resource.name);
52
+ }
53
+ }
54
+
55
+ /** Instant display zones and future list ranges fail at setup, including untyped config. */
56
+ function validateDateConfig(config: AdminConfig): void {
57
+ if (config.timeZone !== undefined) {
58
+ try {
59
+ assertIanaTimeZone(config.timeZone);
60
+ } catch (error) {
61
+ throw new RangeError(`Admin timeZone: ${error instanceof Error ? error.message : String(error)}`);
62
+ }
63
+ }
64
+ for (const resource of config.resources ?? []) {
65
+ const range = resource.dateRange;
66
+ if (range === undefined) continue;
67
+ const at = `Admin resource "${resource.name}", dateRange`;
68
+ if (range === null || typeof range !== 'object' || typeof range.field !== 'string' || range.field.trim() === '') {
69
+ throw new TypeError(`${at}: field must name a declared instant datetime field`);
70
+ }
71
+ if (RESERVED_PARAMS.has(range.field)) {
72
+ throw new TypeError(`${at} field "${range.field}": the URL reserves this name for list view state, so it cannot carry a range`);
73
+ }
74
+ const field = resource.fields?.find(candidate => candidate.name === range.field);
75
+ if (!field || field.type !== 'datetime' || field.virtual || field.storage === 'wall') {
76
+ throw new TypeError(`${at} field "${range.field}": must name a non-virtual instant datetime field (storage omitted or 'utc')`);
77
+ }
78
+ if (isDashboardDimension(resource, range.field)) {
79
+ throw new TypeError(`${at} field "${range.field}": a dashboard dimension owns its URL values and cannot also have a date range row`);
80
+ }
81
+ if (range.weekStartsOn !== undefined && (!Number.isInteger(range.weekStartsOn) || range.weekStartsOn < 0 || range.weekStartsOn > 6)) {
82
+ throw new TypeError(`${at}: weekStartsOn must be an integer from 0 to 6`);
83
+ }
84
+ }
85
+ }