@object-ui/data-objectstack 16.1.0 → 17.1.0

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.
package/src/index.ts CHANGED
@@ -10,9 +10,11 @@ import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from
10
10
  import type {
11
11
  DataSource,
12
12
  BatchTransactionOperation,
13
- MutationEvent,
13
+ DataSourceMutationEvent,
14
14
  QueryParams,
15
15
  QueryResult,
16
+ GlobalSearchResult,
17
+ GlobalSearchHit,
16
18
  FileUploadResult,
17
19
  ExportDownloadRequest,
18
20
  ImportRequestOptions,
@@ -24,6 +26,7 @@ import type {
24
26
  ImportJobUndoResult,
25
27
  ListImportJobsOptions,
26
28
  } from '@object-ui/types';
29
+ import { errorCodeIsAnyOf } from '@object-ui/types';
27
30
  import {
28
31
  convertFiltersToAST,
29
32
  emulateBatchTransaction,
@@ -36,6 +39,7 @@ import {
36
39
  MetadataNotFoundError,
37
40
  BulkOperationError,
38
41
  ConnectionError,
42
+ ValidationError,
39
43
  createErrorFromResponse,
40
44
  } from './errors';
41
45
 
@@ -44,8 +48,15 @@ import {
44
48
  * (e.g. `lead.view.ts`) to the canonical operator symbols expected by the
45
49
  * ObjectStack server's filter AST. Unknown operators fall through unchanged
46
50
  * so existing AST-style entries keep working.
51
+ *
52
+ * Every VALUE here must be a member of the spec's `VALID_AST_OPERATORS`
53
+ * (`@objectstack/spec/data`) — that set gates `isFilterAST()`, and a filter it
54
+ * rejects is not converted, not validated, and then silently DROPPED by
55
+ * driver-sql (objectstack#3948). Pinned by `filter-operator-ast-parity.test.ts`.
56
+ *
57
+ * Exported for that test. @internal
47
58
  */
48
- const FILTER_OPERATOR_ALIASES: Record<string, string> = {
59
+ export const FILTER_OPERATOR_ALIASES: Record<string, string> = {
49
60
  equals: '=',
50
61
  eq: '=',
51
62
  '==': '=',
@@ -82,6 +93,15 @@ const FILTER_OPERATOR_ALIASES: Record<string, string> = {
82
93
  isnull: 'isnull',
83
94
  is_not_null: 'isnotnull',
84
95
  isnotnull: 'isnotnull',
96
+ // Date comparisons. `before`/`after` are CANONICAL members of the spec's
97
+ // `VIEW_FILTER_OPERATORS` (ui/view.zod.ts), so a stored view legitimately
98
+ // carries them — but they are absent from `VALID_AST_OPERATORS`
99
+ // (data/filter.zod.ts), which gates `isFilterAST()`. Without these two
100
+ // entries they reached the wire unchanged, the server's AST gate rejected
101
+ // the shape, and driver-sql skipped the filter ENTIRELY — an unfiltered
102
+ // result set with no error anywhere. objectstack#3948.
103
+ before: '<',
104
+ after: '>',
85
105
  };
86
106
 
87
107
  function normalizeFilterOperator(op: unknown): string | null {
@@ -90,15 +110,159 @@ function normalizeFilterOperator(op: unknown): string | null {
90
110
  return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
91
111
  }
92
112
 
113
+ /**
114
+ * A filter entry this adapter cannot translate into an AST tuple.
115
+ *
116
+ * Thrown rather than skipped. Dropping one entry out of an `and` WIDENS the
117
+ * result set, and dropping the last one emits no `filter=` at all — every row,
118
+ * no error, from a query that asked for a subset. That is the same silent
119
+ * over-fetch the server-side drivers stopped doing in objectstack#3948, and
120
+ * skipping it here just moves it one layer up.
121
+ *
122
+ * Carries the code and status the data API uses for its own version of this
123
+ * refusal (objectstack#4121) so a failed list renders "this view's filter is
124
+ * malformed" rather than "check your connection" (#3066).
125
+ */
126
+ export class MalformedFilterError extends Error {
127
+ readonly code = 'INVALID_FILTER';
128
+ readonly httpStatus = 400;
129
+ readonly entry: unknown;
130
+ readonly index: number;
131
+ constructor(entry: unknown, index: number) {
132
+ const shown = JSON.stringify(entry) ?? String(entry);
133
+ super(
134
+ `Filter entry ${index} is not a usable filter rule (${shown}). `
135
+ + 'Expected { field, operator, value } with a non-empty field.',
136
+ );
137
+ this.name = 'MalformedFilterError';
138
+ this.entry = entry;
139
+ this.index = index;
140
+ }
141
+ }
142
+
143
+ /** Detect the malformed-filter refusal, whether raised here or by the server. */
144
+ export function isMalformedFilterError(error: unknown): boolean {
145
+ if (!error || typeof error !== 'object') return false;
146
+ const e = error as Record<string, unknown>;
147
+ return e.code === 'INVALID_FILTER' || e.name === 'MalformedFilterError';
148
+ }
149
+
93
150
  function objectFilterEntryToAST(entry: any): [string, string, any] | null {
94
151
  if (!entry || typeof entry !== 'object') return null;
95
- const field = entry.field ?? entry.name;
152
+ // `field` only. A `?? entry.name` fallback lived here from the day the
153
+ // function was written (4b93db4e6) and was unreachable for exactly as long:
154
+ // the shape sniff below has always keyed on `field`, so a `name`-keyed entry
155
+ // fell through to the "already AST" branch and shipped raw. The spec agrees
156
+ // it is not a real shape — `ViewFilterRuleSchema.field` is required, so a
157
+ // `name`-keyed rule cannot be saved as view metadata in the first place.
158
+ const field = entry.field;
96
159
  const rawOp = entry.operator ?? entry.op ?? '=';
97
160
  const op = normalizeFilterOperator(rawOp);
98
161
  if (!field || !op) return null;
99
162
  return [String(field), op, entry.value];
100
163
  }
101
164
 
165
+ /**
166
+ * Which of the two array filter shapes is this — object entries or an AST node?
167
+ *
168
+ * One definition on purpose. This test used to be written out twice (here and
169
+ * inline in `convertQueryParams`) and the copies had already drifted: the
170
+ * inline one omitted the `!== null` guard, so a `$filter` of `[null]` threw a
171
+ * TypeError on the plain `find()` path while the same value was handled on the
172
+ * `$expand` path. Same stored view, different answer, decided by whether it
173
+ * happened to expand a lookup.
174
+ */
175
+ function isObjectFilterEntryForm(filter: readonly unknown[]): boolean {
176
+ const first = filter[0];
177
+ return filter.length > 0
178
+ && typeof first === 'object'
179
+ && first !== null
180
+ && !Array.isArray(first)
181
+ && (first as any).field !== undefined;
182
+ }
183
+
184
+ /**
185
+ * Translate `[{ field, operator, value }, ...]` into a filter AST node. Every
186
+ * entry must translate; see `MalformedFilterError` for why one that doesn't is
187
+ * an error rather than an omission.
188
+ */
189
+ function objectFilterEntriesToAST(entries: readonly unknown[]): unknown[] {
190
+ const nodes = entries.map((entry, i) => {
191
+ // An entry that is itself an array is already a node — a mixed array keeps
192
+ // both conditions instead of losing one to a drop or the whole query to an
193
+ // error.
194
+ if (Array.isArray(entry)) return translateFilterChild(entry);
195
+ const tuple = objectFilterEntryToAST(entry);
196
+ if (!tuple) throw new MalformedFilterError(entry, i);
197
+ return tuple;
198
+ });
199
+ return nodes.length === 1 ? (nodes[0] as unknown[]) : ['and', ...nodes];
200
+ }
201
+
202
+ /** Logical heads `parseFilterAST` recognizes (`data/filter.zod.ts`). No `not`. */
203
+ const LOGICAL_AST_HEADS = new Set(['and', 'or']);
204
+
205
+ /**
206
+ * Translate a filter array at EVERY level, not just the top one.
207
+ *
208
+ * Translating only the top level left the commonest composite filter there is
209
+ * shipping raw. A list whose view carries a stored filter and whose user adds
210
+ * one in the panel produces `['and', <ViewFilterRule[]>, <AST tuples>]`; the
211
+ * head is the string `and`, so the old top-level-only check called the whole
212
+ * thing "already AST" and sent the rules on untouched.
213
+ *
214
+ * What the server does with that depends on its version, and both answers are
215
+ * wrong:
216
+ *
217
+ * ```
218
+ * const n = ['and', [{ field: 'stage', operator: 'eq', value: 'won' }], [['amount', '>', 1]]];
219
+ * isFilterAST(n) // false — a bare rule object is not an AST child
220
+ * parseFilterAST(n) // { amount: { $gt: 1 } } ← `stage = won` is simply GONE
221
+ * ```
222
+ *
223
+ * Since objectstack#4121 the `isFilterAST` gate turns that into a 400 and the
224
+ * list fails to load. Before it — or anywhere `parseFilterAST` is reached
225
+ * without the gate — the view's own condition is dropped without a word and the
226
+ * list shows records the view exists to exclude.
227
+ */
228
+ function translateFilterArray(filter: unknown[]): unknown[] {
229
+ if (isObjectFilterEntryForm(filter)) return objectFilterEntriesToAST(filter);
230
+ const head = filter[0];
231
+ if (typeof head === 'string' && LOGICAL_AST_HEADS.has(head.toLowerCase())) {
232
+ return [head, ...filter.slice(1).map(translateFilterChild)];
233
+ }
234
+ // Legacy flat array of child nodes: [[...], [...]] — implicit AND.
235
+ if (filter.every((child) => Array.isArray(child))) return filter.map(translateFilterChild);
236
+ // A comparison tuple, or a shape we do not recognize. Leave it alone; the
237
+ // server decides, and since objectstack#4121 it says so with a 400.
238
+ return filter;
239
+ }
240
+
241
+ /**
242
+ * A child of a logical node: another array node, a bare rule object, or a value
243
+ * we leave alone.
244
+ *
245
+ * The bare-rule case comes from producers that SPREAD a `ViewFilterRule[]` into
246
+ * an `and` (`['and', ...rules, ...tuples]`) instead of wrapping it. That puts
247
+ * rule objects where the AST expects nodes, and the server has no good answer:
248
+ * `isFilterAST` rejects it (a 400 since objectstack#4121), while
249
+ * `parseFilterAST` reads the rule as a MongoDB condition and filters on columns
250
+ * literally named `field` / `operator` / `value` — three columns that do not
251
+ * exist, so the honest-looking result is empty.
252
+ *
253
+ * Only rule-SHAPED objects are translated: a child with no `field` is a genuine
254
+ * MongoDB condition (`{ status: 'active' }`) and must pass through untouched.
255
+ * Same discriminator `isObjectFilterEntryForm` uses at the top level.
256
+ */
257
+ function translateFilterChild(child: unknown): unknown {
258
+ if (Array.isArray(child)) return child.length > 0 ? translateFilterArray(child) : child;
259
+ if (child && typeof child === 'object' && (child as any).field !== undefined) {
260
+ const tuple = objectFilterEntryToAST(child);
261
+ if (tuple) return tuple;
262
+ }
263
+ return child;
264
+ }
265
+
102
266
  /**
103
267
  * Translate any of the filter shapes accepted by ObjectUI into the AST format
104
268
  * understood by the ObjectStack server's `parseFilterAST()`.
@@ -118,30 +282,68 @@ function translateFilterToAST(filter: unknown): unknown | undefined {
118
282
 
119
283
  if (Array.isArray(filter)) {
120
284
  if (filter.length === 0) return undefined;
121
-
122
- // Object form: [{ field, operator, value }, ...]
123
- const first = filter[0];
124
- const isObjectForm = filter.length > 0
125
- && typeof first === 'object'
126
- && first !== null
127
- && !Array.isArray(first)
128
- && (first as any).field !== undefined;
129
- if (isObjectForm) {
130
- const tuples = (filter as any[])
131
- .map(entry => objectFilterEntryToAST(entry))
132
- .filter((t): t is [string, string, any] => t !== null);
133
- if (tuples.length === 0) return undefined;
134
- if (tuples.length === 1) return tuples[0];
135
- return ['and', ...tuples];
136
- }
137
-
138
- // Already AST — pass through.
139
- return filter;
285
+ return translateFilterArray(filter);
140
286
  }
141
287
 
142
288
  if (typeof filter === 'object') {
143
289
  if (Object.keys(filter as Record<string, unknown>).length === 0) return undefined;
144
- return filter;
290
+ // Same conversion `convertQueryParams` applies. This branch used to return
291
+ // the object VERBATIM, so the two `find()` routes disagreed about the same
292
+ // filter — decided, as ever, by whether the query happened to expand a
293
+ // lookup. Measured across 21 operator shapes, four diverged; the one that
294
+ // mattered is that `convertFiltersToAST`'s unknown-operator guard — added
295
+ // expressly "to avoid silent failure" — never ran on this route, so a typo'd
296
+ // operator threw on a plain read and shipped silently on an expanded one.
297
+ return convertFiltersToAST(filter as Record<string, unknown>);
298
+ }
299
+
300
+ return undefined;
301
+ }
302
+
303
+ /**
304
+ * Serialize a `$orderby` to the server's `sort` shorthand
305
+ * (`field,-other_field`), for every shape `QueryParams['$orderby']` declares.
306
+ *
307
+ * The type declares four — `string`, `string[]`, `SortNode[]`,
308
+ * `Record<field, direction>` — and the two `find()` routes each open-coded a
309
+ * fold that handled three of them. The missing one was the bare string, and it
310
+ * did not degrade quietly: `Object.entries('name asc')` enumerates a string's
311
+ * character indices, so the request went out as `sort=0,1,2,3,4,5,6,7`. Against
312
+ * a server that rejects an unreadable sort rather than ignoring it
313
+ * (objectstack#4226), that is a `400 INVALID_SORT` and an empty list — so a
314
+ * standalone `ObjectGrid` with a `sort` in its metadata, which is exactly the
315
+ * shape it builds (`ObjectGrid.tsx`: `` `${field} ${order}` ``), failed to load
316
+ * at all.
317
+ *
318
+ * One serializer for both routes, for the reason the filter path already has
319
+ * one: two copies of a fold can only agree by inspection, and these two did not.
320
+ *
321
+ * Returns `undefined` when nothing is sortable, so callers skip the parameter
322
+ * entirely rather than sending an empty one.
323
+ */
324
+ export function serializeOrderBy(orderby: QueryParams['$orderby']): string | undefined {
325
+ if (orderby === undefined || orderby === null) return undefined;
326
+
327
+ // `field asc` / `-field` / a comma-separated list of either — already the
328
+ // wire shorthand the server parses, so it rides through untouched.
329
+ if (typeof orderby === 'string') {
330
+ const trimmed = orderby.trim();
331
+ return trimmed.length > 0 ? trimmed : undefined;
332
+ }
333
+
334
+ const shorthand = (field: string, order?: unknown) =>
335
+ String(order).toLowerCase() === 'desc' ? `-${field}` : field;
336
+
337
+ if (Array.isArray(orderby)) {
338
+ const parts = orderby
339
+ .map((item) => (typeof item === 'string' ? item.trim() : shorthand(item.field, item.order)))
340
+ .filter((s) => s.length > 0);
341
+ return parts.length > 0 ? parts.join(',') : undefined;
342
+ }
343
+
344
+ if (typeof orderby === 'object') {
345
+ const parts = Object.entries(orderby).map(([field, order]) => shorthand(field, order));
346
+ return parts.length > 0 ? parts.join(',') : undefined;
145
347
  }
146
348
 
147
349
  return undefined;
@@ -233,7 +435,108 @@ export function is404Error(error: unknown): boolean {
233
435
  return true;
234
436
  }
235
437
  const code = typeof err.code === 'string' ? err.code : '';
236
- return code === 'object_not_found' || code === 'record_not_found';
438
+ return errorCodeIsAnyOf({ code }, ['OBJECT_NOT_FOUND', 'RECORD_NOT_FOUND']);
439
+ }
440
+
441
+ /**
442
+ * Thrown when the deployment has no analytics capability installed
443
+ * (framework#3891 / #4019).
444
+ *
445
+ * The framework retired its degraded in-kernel analytics fallback — it dropped
446
+ * the caller's RLS/tenant scope and ignored the contract filter, so it answered
447
+ * 200 with over-broad numbers. `@objectstack/service-analytics` is now the
448
+ * domain's only implementation, and a deployment without it answers:
449
+ *
450
+ * - `POST /api/v1/analytics/query` → **404** (the routes aren't even mounted);
451
+ * - `POST /api/v1/analytics/dataset/query` → **501 NOT_IMPLEMENTED**.
452
+ *
453
+ * Neither is a bug to report as a stack trace: it is a deployment that hasn't
454
+ * installed the capability. This error carries a message a UI can show as-is.
455
+ */
456
+ export class AnalyticsNotInstalledError extends Error {
457
+ readonly code = 'ANALYTICS_NOT_INSTALLED';
458
+ /** The surface that was unavailable, for the message a host renders. */
459
+ readonly surface: string;
460
+ constructor(surface: string, detail?: string) {
461
+ super(
462
+ `Analytics capability is not installed on this deployment — ${surface} is unavailable. ` +
463
+ 'Install @objectstack/service-analytics and mount AnalyticsServicePlugin to enable it.' +
464
+ (detail ? ` (server said: ${detail})` : ''),
465
+ );
466
+ this.name = 'AnalyticsNotInstalledError';
467
+ this.surface = surface;
468
+ }
469
+ }
470
+
471
+ /** True when `error` is an {@link AnalyticsNotInstalledError} (or its wire twin). */
472
+ export function isAnalyticsNotInstalledError(error: unknown): boolean {
473
+ if (!error || typeof error !== 'object') return false;
474
+ return (error as { code?: unknown }).code === 'ANALYTICS_NOT_INSTALLED';
475
+ }
476
+
477
+ /**
478
+ * Thrown when the server REJECTED the analytics query body (HTTP 400 —
479
+ * `VALIDATION_FAILED` since framework#4010 validates `/analytics/query` at the
480
+ * entry against the canonical bare `AnalyticsQuery` shape).
481
+ *
482
+ * Distinct from {@link AnalyticsNotInstalledError} on purpose: this one is a
483
+ * defect in what WE sent, so it must never be answered with the client-side
484
+ * fallback. Numbers produced by a different code path would look plausible and
485
+ * bury the contract violation — the misdirection framework#3878 documented.
486
+ */
487
+ export class AnalyticsQueryRejectedError extends Error {
488
+ readonly code = 'ANALYTICS_QUERY_REJECTED';
489
+ /** The server's own error code (e.g. `VALIDATION_FAILED`), when it sent one. */
490
+ readonly serverCode?: string;
491
+ constructor(detail?: string, serverCode?: string) {
492
+ super(
493
+ `Analytics rejected the query: ${detail ?? 'the request body does not match the AnalyticsQuery contract'}`,
494
+ );
495
+ this.name = 'AnalyticsQueryRejectedError';
496
+ this.serverCode = serverCode;
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Classify a FAILED analytics call so the caller knows whether to degrade or
502
+ * to surface the failure.
503
+ *
504
+ * `@objectstack/client`'s fetch wrapper throws on a non-2xx, decorating the
505
+ * error with the semantic `code` string and the numeric `httpStatus` (the
506
+ * ADR-0112 / framework#3842 shape this repo already reads elsewhere). Two
507
+ * outcomes must NOT be conflated:
508
+ *
509
+ * - **`not-installed`** — the deployment has no analytics service. Since
510
+ * framework#3891 retired the degraded in-kernel shim, that is a 404 (the
511
+ * routes aren't mounted at all, framework#4019) or a 501 `NOT_IMPLEMENTED`
512
+ * (REST's dataset route with no service behind it). Degrading to a
513
+ * client-side aggregate over a scoped `find()` is CORRECT here.
514
+ * - **`rejected`** — the server refused OUR body (400 `VALIDATION_FAILED`;
515
+ * framework#4010 validates `/analytics/query` at the entry). Degrading
516
+ * would answer our own contract violation with plausible numbers from a
517
+ * different code path and bury it — the misdirection framework#3878
518
+ * documented. It must be surfaced.
519
+ *
520
+ * Anything else (5xx, network) is `unknown`: degrade, but silently — it is a
521
+ * transient failure, not a deployment that is missing a capability.
522
+ */
523
+ export function classifyAnalyticsFailure(
524
+ error: unknown,
525
+ ): { kind: 'not-installed' | 'rejected' | 'unknown'; code?: string; message?: string } {
526
+ const err = (error ?? {}) as Record<string, unknown>;
527
+ const code = typeof err.code === 'string' ? err.code : undefined;
528
+ const message = typeof err.message === 'string' ? err.message : undefined;
529
+ const status =
530
+ typeof err.httpStatus === 'number' ? err.httpStatus
531
+ : typeof err.status === 'number' ? err.status
532
+ : typeof err.statusCode === 'number' ? err.statusCode
533
+ : undefined;
534
+
535
+ if (code === 'VALIDATION_FAILED' || status === 400) return { kind: 'rejected', code, message };
536
+ if (status === 404 || status === 501 || code === 'NOT_IMPLEMENTED' || code === 'ROUTE_NOT_FOUND') {
537
+ return { kind: 'not-installed', code, message };
538
+ }
539
+ return { kind: 'unknown', code, message };
237
540
  }
238
541
 
239
542
  /**
@@ -279,17 +582,55 @@ export function isConcurrentUpdateError(error: unknown): error is ConcurrentUpda
279
582
  }
280
583
 
281
584
  /**
282
- * Convert any error thrown by the upstream client into a typed
283
- * `ConcurrentUpdateError` when it represents a 409 CONCURRENT_UPDATE.
284
- * Returns the original error untouched otherwise. Callers can simply
285
- * `throw normaliseClientError(err)` from their catch blocks.
585
+ * Convert any error thrown by the upstream client into a typed error when we
586
+ * recognise its shape. Returns the original error untouched otherwise, so
587
+ * callers can simply `throw normaliseClientError(err)` from their catch blocks.
588
+ *
589
+ * Two shapes are recognised:
590
+ * - `409` + `CONCURRENT_UPDATE` → {@link ConcurrentUpdateError};
591
+ * - `400` + `VALIDATION_FAILED` → {@link ValidationError}, carrying the
592
+ * server's per-field entries so a form can mark the offending inputs
593
+ * instead of showing one undirected toast.
286
594
  */
287
595
  export function normaliseClientError(error: unknown): unknown {
288
596
  if (!error || typeof error !== 'object') return error;
289
597
  const e = error as Record<string, unknown>;
598
+ // The client sets `details` to the parsed body's `details`, falling back to
599
+ // the WHOLE body — and the validation envelope has no `details` key, so this
600
+ // is where `fields[]` lands.
601
+ const details = (e.details ?? {}) as Record<string, unknown>;
602
+
603
+ if (e.code === 'VALIDATION_FAILED' || e.name === 'ValidationError') {
604
+ const rawFields = Array.isArray(details.fields)
605
+ ? details.fields
606
+ : Array.isArray((e as { fields?: unknown }).fields)
607
+ ? ((e as { fields: unknown[] }).fields)
608
+ : [];
609
+ const validationErrors = rawFields
610
+ .map((f) => {
611
+ const rec = (f ?? {}) as Record<string, unknown>;
612
+ const field = typeof rec.field === 'string' ? rec.field : undefined;
613
+ if (!field) return null;
614
+ const message =
615
+ typeof rec.message === 'string' && rec.message.trim()
616
+ ? rec.message
617
+ : typeof rec.code === 'string'
618
+ ? rec.code
619
+ : '';
620
+ return { field, message };
621
+ })
622
+ .filter((x): x is { field: string; message: string } => x !== null);
623
+
624
+ return new ValidationError(
625
+ typeof e.message === 'string' ? e.message : 'Validation failed',
626
+ validationErrors[0]?.field,
627
+ validationErrors,
628
+ { fields: rawFields },
629
+ );
630
+ }
631
+
290
632
  if (e.code !== 'CONCURRENT_UPDATE' && e.httpStatus !== 409) return error;
291
633
  if (e.code !== 'CONCURRENT_UPDATE') return error;
292
- const details = (e.details ?? {}) as Record<string, unknown>;
293
634
  return new ConcurrentUpdateError({
294
635
  currentVersion: typeof details.currentVersion === 'string' ? details.currentVersion : null,
295
636
  currentRecord: details.currentRecord ?? null,
@@ -315,7 +656,7 @@ function createQuietHttpLogger(): any {
315
656
  const errBody = meta.error;
316
657
  if (errBody && typeof errBody === 'object') {
317
658
  const code = (errBody as Record<string, unknown>).code;
318
- if (code === 'object_not_found' || code === 'record_not_found') return true;
659
+ if (errorCodeIsAnyOf({ code }, ['OBJECT_NOT_FOUND', 'RECORD_NOT_FOUND'])) return true;
319
660
  }
320
661
  return false;
321
662
  };
@@ -377,6 +718,36 @@ export type ConnectionStateListener = (event: ConnectionStateEvent) => void;
377
718
  */
378
719
  export type BatchProgressListener = (event: BatchProgressEvent) => void;
379
720
 
721
+ /**
722
+ * One server-reported write-strip: caller-supplied fields the backend LEGALLY
723
+ * removed from a write before persisting (a non-system caller cannot seed a
724
+ * `readonly` field, a `readonlyWhen` predicate locked it, etc.). Mirrors the
725
+ * framework `DroppedFieldsEvent` (spec `DroppedFieldsEventSchema`) structurally
726
+ * so we don't pin a client type version — `reason` is kept as a widened string
727
+ * for forward-compatibility with reasons added server-side.
728
+ */
729
+ export interface DroppedFieldsEvent {
730
+ object: string;
731
+ fields: string[];
732
+ reason: string;
733
+ }
734
+
735
+ /**
736
+ * Emitted after a create/update whose response carried `droppedFields`
737
+ * (framework #3431/#3455). The write SUCCEEDED — this is a warning that some
738
+ * supplied fields never landed, so the UI can tell the user rather than let it
739
+ * pass silently. Subscribe via {@link ObjectStackAdapter.onWriteWarning}.
740
+ */
741
+ export interface WriteWarningEvent {
742
+ operation: 'create' | 'update';
743
+ resource: string;
744
+ id?: string | number;
745
+ droppedFields: DroppedFieldsEvent[];
746
+ }
747
+
748
+ /** Event listener type for write-warning (dropped-fields) events. */
749
+ export type WriteWarningListener = (event: WriteWarningEvent) => void;
750
+
380
751
  // Re-export FileUploadResult from types for consumers
381
752
  export type { FileUploadResult } from '@object-ui/types';
382
753
 
@@ -437,6 +808,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
437
808
  private reconnectAttempts: number = 0;
438
809
  private baseUrl: string;
439
810
  private token?: string;
811
+ /** One "analytics capability is missing" console line per adapter, not per widget. */
812
+ private analyticsCapabilityWarned = false;
440
813
  private fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
441
814
  // In-flight find() requests keyed by resource + serialized params.
442
815
  // Coalesces concurrent identical reads (e.g. React StrictMode double-mount,
@@ -467,7 +840,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
467
840
  // create/update/delete so data-bound views (ListView, ObjectView, kanban,
468
841
  // calendar) auto-refresh — the interface ListView relies on to reflect
469
842
  // inline-edit "Save All" writes without a manual reload.
470
- private mutationListeners = new Set<(event: MutationEvent<T>) => void>();
843
+ private mutationListeners = new Set<(event: DataSourceMutationEvent<T>) => void>();
844
+
845
+ // Subscribers registered via onWriteWarning(). Emitted after a create/update
846
+ // whose response carried `droppedFields` (framework #3431/#3455) so the app
847
+ // shell can surface a toast instead of the strip passing silently.
848
+ private writeWarningListeners = new Set<WriteWarningListener>();
471
849
 
472
850
  constructor(config: {
473
851
  baseUrl: string;
@@ -727,6 +1105,103 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
727
1105
  return promise;
728
1106
  }
729
1107
 
1108
+ /**
1109
+ * Full-text search across every searchable object in a single round-trip.
1110
+ *
1111
+ * Hits `GET /api/v1/search?q=`, the platform's global search endpoint served
1112
+ * by the registered search service (the pinyin full-text plugin) and backed
1113
+ * by `metadata-protocol`'s `searchAll`. Unlike `find(resource, { $search })`
1114
+ * — a per-object metadata-driven search (ADR-0061) — this consults the search
1115
+ * index and ranks hits across objects, so it surfaces records the per-object
1116
+ * fanout misses. Global affordances (⌘K command palette, search page) prefer
1117
+ * this path (framework #3371).
1118
+ *
1119
+ * Returns `{ query, hits }`. A backend without the search plugin installed
1120
+ * answers `404`; we treat that as "no global search here" and return an empty
1121
+ * hit set so callers can fall back to a per-object fanout rather than surface
1122
+ * an error.
1123
+ */
1124
+ async searchAll(
1125
+ query: string,
1126
+ options?: { limit?: number; objects?: string[] },
1127
+ ): Promise<GlobalSearchResult> {
1128
+ const trimmed = (query ?? '').trim();
1129
+ if (trimmed === '') return { query: '', hits: [] };
1130
+
1131
+ await this.connect();
1132
+
1133
+ const queryParams = new URLSearchParams();
1134
+ queryParams.set('q', trimmed);
1135
+ if (options?.limit != null && options.limit > 0) {
1136
+ queryParams.set('limit', String(options.limit));
1137
+ }
1138
+ if (options?.objects && options.objects.length > 0) {
1139
+ queryParams.set('objects', options.objects.join(','));
1140
+ }
1141
+
1142
+ const baseUrl = (this.baseUrl || '').replace(/\/$/, '');
1143
+ // Avoid doubling /api/v1 when baseUrl already carries the version suffix.
1144
+ const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
1145
+ const searchPath = hasApiVersionSuffix ? '/search' : '/api/v1/search';
1146
+ const url = `${baseUrl}${searchPath}?${queryParams.toString()}`;
1147
+
1148
+ const headers: Record<string, string> = {
1149
+ 'Content-Type': 'application/json',
1150
+ };
1151
+ if (this.token) {
1152
+ headers['Authorization'] = `Bearer ${this.token}`;
1153
+ }
1154
+
1155
+ const res = await this.fetchImpl(url, { method: 'GET', headers });
1156
+
1157
+ if (!res.ok) {
1158
+ // 404 → the search plugin isn't installed on this backend. Degrade to an
1159
+ // empty result so the caller can fall back instead of hard-failing.
1160
+ if (res.status === 404) return { query: trimmed, hits: [] };
1161
+ const errorBody = await res.json().catch(() => ({ message: res.statusText }));
1162
+ const err = new Error(
1163
+ errorBody?.error?.message || errorBody?.message || res.statusText,
1164
+ ) as Error & { status?: number };
1165
+ err.status = res.status;
1166
+ throw err;
1167
+ }
1168
+
1169
+ const body = await res.json();
1170
+ // Unwrap the standard `{ success, data }` envelope when present; the search
1171
+ // endpoint itself returns `{ query, hits }`.
1172
+ const payload =
1173
+ body && typeof body === 'object' && typeof body.success === 'boolean' && 'data' in body
1174
+ ? body.data
1175
+ : body;
1176
+
1177
+ const rawHits: unknown[] = Array.isArray(payload?.hits)
1178
+ ? payload.hits
1179
+ : Array.isArray(payload)
1180
+ ? payload
1181
+ : [];
1182
+
1183
+ const hits: GlobalSearchHit[] = [];
1184
+ for (const entry of rawHits) {
1185
+ if (!entry || typeof entry !== 'object') continue;
1186
+ const h = entry as Record<string, any>;
1187
+ const object = h.object ?? h.objectName ?? h.object_name;
1188
+ const id = h.id ?? h.record?.id ?? h.record?._id;
1189
+ if (typeof object !== 'string' || id == null) continue;
1190
+ hits.push({
1191
+ object,
1192
+ id: String(id),
1193
+ title: typeof h.title === 'string' ? h.title : undefined,
1194
+ snippet: typeof h.snippet === 'string' ? h.snippet : undefined,
1195
+ record: h.record && typeof h.record === 'object' ? h.record : undefined,
1196
+ });
1197
+ }
1198
+
1199
+ return {
1200
+ query: typeof payload?.query === 'string' ? payload.query : trimmed,
1201
+ hits,
1202
+ };
1203
+ }
1204
+
730
1205
  /**
731
1206
  * Find a single record by ID.
732
1207
  */
@@ -780,7 +1255,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
780
1255
  * Notify all mutation subscribers. A throwing listener must not break the
781
1256
  * mutation or starve the other subscribers, so each is isolated.
782
1257
  */
783
- private emitMutation(event: MutationEvent<T>): void {
1258
+ private emitMutation(event: DataSourceMutationEvent<T>): void {
784
1259
  for (const listener of this.mutationListeners) {
785
1260
  try {
786
1261
  listener(event);
@@ -796,18 +1271,110 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
796
1271
  * mutation (e.g. inline-edit "Save All", which writes through `update` and
797
1272
  * must repaint the list without a manual reload).
798
1273
  */
799
- onMutation(callback: (event: MutationEvent<T>) => void): () => void {
1274
+ onMutation(callback: (event: DataSourceMutationEvent<T>) => void): () => void {
800
1275
  this.mutationListeners.add(callback);
801
1276
  return () => {
802
1277
  this.mutationListeners.delete(callback);
803
1278
  };
804
1279
  }
805
1280
 
1281
+ /**
1282
+ * Notify all write-warning subscribers. Isolated like {@link emitMutation}: a
1283
+ * throwing listener must not break the write or starve the others.
1284
+ */
1285
+ private emitWriteWarning(event: WriteWarningEvent): void {
1286
+ for (const listener of this.writeWarningListeners) {
1287
+ try {
1288
+ listener(event);
1289
+ } catch (err) {
1290
+ console.warn('ObjectStackAdapter: write-warning listener error', err);
1291
+ }
1292
+ }
1293
+ }
1294
+
1295
+ /**
1296
+ * Read `droppedFields` off a create/update response (framework #3431/#3455)
1297
+ * and, when present, notify write-warning subscribers. Tolerant of a client
1298
+ * whose response type predates `droppedFields`: the field is read structurally
1299
+ * and validated, so an older client (or a backend that never drops) is a no-op.
1300
+ */
1301
+ private notifyDroppedFields(
1302
+ operation: 'create' | 'update',
1303
+ resource: string,
1304
+ result: unknown,
1305
+ id?: string | number,
1306
+ ): void {
1307
+ const dropped = (result as { droppedFields?: unknown } | null | undefined)?.droppedFields;
1308
+ if (!Array.isArray(dropped) || dropped.length === 0) return;
1309
+ const droppedFields = dropped.filter(
1310
+ (e): e is DroppedFieldsEvent =>
1311
+ !!e && typeof e === 'object' && Array.isArray((e as DroppedFieldsEvent).fields) && (e as DroppedFieldsEvent).fields.length > 0,
1312
+ );
1313
+ if (droppedFields.length === 0) return;
1314
+ this.emitWriteWarning({ operation, resource, ...(id !== undefined ? { id } : {}), droppedFields });
1315
+ }
1316
+
1317
+ /**
1318
+ * Same, for the cross-object transactional batch (framework #3794). Its
1319
+ * response hangs the events off a top-level `droppedFields` list, each tagged
1320
+ * with the `index` of the operation it came from — `results` entries are bare
1321
+ * record echoes with nowhere to hang a per-row list.
1322
+ *
1323
+ * This is the path that matters most for the warning: `batchTransaction` is
1324
+ * how the console's record form saves a master-detail record, so a
1325
+ * `readonlyWhen`-locked field edited in that form was stripped server-side
1326
+ * while the UI reported a plain success. The operation kind is taken from the
1327
+ * originating op so the toast doesn't call an update a create.
1328
+ */
1329
+ private notifyBatchDroppedFields(
1330
+ operations: BatchTransactionOperation[],
1331
+ payload: unknown,
1332
+ ): void {
1333
+ const dropped = (payload as { droppedFields?: unknown } | null | undefined)?.droppedFields;
1334
+ if (!Array.isArray(dropped) || dropped.length === 0) return;
1335
+ for (const entry of dropped) {
1336
+ if (!entry || typeof entry !== 'object') continue;
1337
+ const e = entry as DroppedFieldsEvent & { index?: number };
1338
+ if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
1339
+ const op = typeof e.index === 'number' ? operations[e.index] : undefined;
1340
+ // `delete` never drops fields; anything unexpected reads as an update,
1341
+ // which is the truthful default for a batch that echoed a strip.
1342
+ const operation: 'create' | 'update' = (op?.action ?? 'create') === 'create' ? 'create' : 'update';
1343
+ this.emitWriteWarning({
1344
+ operation,
1345
+ resource: e.object ?? op?.object ?? '',
1346
+ ...(op?.id !== undefined && op?.id !== null ? { id: op.id } : {}),
1347
+ droppedFields: [{ object: e.object, fields: e.fields, reason: e.reason }],
1348
+ });
1349
+ }
1350
+ }
1351
+
1352
+ /**
1353
+ * Subscribe to write-warning events (a create/update dropped caller-supplied
1354
+ * fields — #3431/#3455). Returns an unsubscribe function. The app shell uses
1355
+ * this to toast the user; the write itself already succeeded.
1356
+ */
1357
+ onWriteWarning(callback: WriteWarningListener): () => void {
1358
+ this.writeWarningListeners.add(callback);
1359
+ return () => {
1360
+ this.writeWarningListeners.delete(callback);
1361
+ };
1362
+ }
1363
+
806
1364
  async create(resource: string, data: Partial<T>): Promise<T> {
807
1365
  await this.connect();
808
- const result = await this.client.data.create<T>(resource, data);
809
- this.emitMutation({ type: 'create', resource, record: { ...result.record } });
810
- return result.record;
1366
+ try {
1367
+ const result = await this.client.data.create<T>(resource, data);
1368
+ this.emitMutation({ type: 'create', resource, record: { ...result.record } });
1369
+ this.notifyDroppedFields('create', resource, result, (result.record as { id?: string | number } | undefined)?.id);
1370
+ return result.record;
1371
+ } catch (err) {
1372
+ // `update` has always normalised; `create` did not, so a rejected insert
1373
+ // reached callers as the raw client error — no typed shape to branch on,
1374
+ // and its `fields[]` unreachable. A create is the path that most often
1375
+ // trips required-field validation, so it needs this more, not less.
1376
+ throw normaliseClientError(err);
1377
+ }
811
1378
  }
812
1379
 
813
1380
  /**
@@ -836,6 +1403,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
836
1403
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : undefined,
837
1404
  );
838
1405
  this.emitMutation({ type: 'update', resource, id, record: { ...result.record } });
1406
+ this.notifyDroppedFields('update', resource, result, id);
839
1407
  return result.record;
840
1408
  } catch (err) {
841
1409
  throw normaliseClientError(err);
@@ -1041,6 +1609,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1041
1609
  // dependency floor (framework #3271). No hand-rolled POST /api/v1/batch.
1042
1610
  const payload = await this.client.data.batchTransaction(operations);
1043
1611
  this.emitBatchMutations(operations, payload?.results);
1612
+ this.notifyBatchDroppedFields(operations, payload);
1044
1613
  return payload;
1045
1614
  } catch (err) {
1046
1615
  // On a non-declaring backend, endpoint missing (404/405) or a runtime that
@@ -1089,7 +1658,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1089
1658
  }
1090
1659
 
1091
1660
  /**
1092
- * Emit one MutationEvent per committed operation so the invalidation bus
1661
+ * Emit one DataSourceMutationEvent per committed operation so the invalidation bus
1093
1662
  * (#2269) sees writes that went through /batch exactly like single
1094
1663
  * create/update/delete calls — master-detail ModalForm saves otherwise leave
1095
1664
  * related lists and count badges stale (#2582). `results` is index-aligned
@@ -1537,22 +2106,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1537
2106
  }
1538
2107
 
1539
2108
  // Sorting
1540
- if (params.$orderby) {
1541
- if (Array.isArray(params.$orderby)) {
1542
- const sortStr = params.$orderby.map(item => {
1543
- if (typeof item === 'string') return item;
1544
- const field = item.field;
1545
- const order = item.order || 'asc';
1546
- return order === 'desc' ? `-${field}` : field;
1547
- }).join(',');
1548
- queryParams.set('sort', sortStr);
1549
- } else {
1550
- const sortStr = Object.entries(params.$orderby)
1551
- .map(([field, order]) => order === 'desc' ? `-${field}` : field)
1552
- .join(',');
1553
- queryParams.set('sort', sortStr);
1554
- }
1555
- }
2109
+ const sortStr = serializeOrderBy(params.$orderby);
2110
+ if (sortStr) queryParams.set('sort', sortStr);
1556
2111
 
1557
2112
  // Filter — translate ViewFilterRule[] (`[{field, operator, value}]`)
1558
2113
  // and other shapes into AST tuples the server understands. Without this,
@@ -1637,6 +2192,16 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1637
2192
  queryParams.set('filter', JSON.stringify(translated));
1638
2193
  }
1639
2194
  }
2195
+ // Search — the other half of what a list is showing. Without it an export
2196
+ // taken while a search is active returns the unsearched superset
2197
+ // (objectstack#4230). Servers predating that ignore the param.
2198
+ const searchTerm = typeof request.search === 'string' ? request.search.trim() : '';
2199
+ if (searchTerm) {
2200
+ queryParams.set('search', searchTerm);
2201
+ if (request.searchFields && request.searchFields.length > 0) {
2202
+ queryParams.set('searchFields', request.searchFields.join(','));
2203
+ }
2204
+ }
1640
2205
 
1641
2206
  const baseUrl = this.baseUrl.replace(/\/$/, '');
1642
2207
  // Avoid doubling /api/v1 if baseUrl already includes the version suffix.
@@ -1689,51 +2254,21 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1689
2254
  // entry into the AST tuple shape and map human-readable
1690
2255
  // operator names (`greater_than_or_equal`, `in`, `contains`,
1691
2256
  // …) to the canonical symbols the server understands.
1692
- const isObjectForm = params.$filter.length > 0
1693
- && typeof params.$filter[0] === 'object'
1694
- && !Array.isArray(params.$filter[0])
1695
- && (params.$filter[0] as any).field !== undefined;
1696
- if (isObjectForm) {
1697
- const tuples = (params.$filter as any[])
1698
- .map(entry => objectFilterEntryToAST(entry))
1699
- .filter((t): t is [string, string, any] => t !== null);
1700
- if (tuples.length === 0) {
1701
- // All entries were unrecognized — drop the filter rather than
1702
- // sending a malformed array.
1703
- } else if (tuples.length === 1) {
1704
- options.filters = tuples[0];
1705
- } else {
1706
- options.filters = ['and', ...tuples];
1707
- }
1708
- } else {
1709
- // Already in AST format
1710
- options.filters = params.$filter;
1711
- }
2257
+ // Shared with `translateFilterToAST` so the two `find()` routes — this
2258
+ // one and the `$expand`/`$search` raw GET — cannot disagree about the
2259
+ // same stored filter.
2260
+ options.filters = translateFilterArray(params.$filter);
1712
2261
  } else {
1713
2262
  options.filters = convertFiltersToAST(params.$filter);
1714
2263
  }
1715
2264
  }
1716
2265
  }
1717
2266
 
1718
- // Sorting - convert to ObjectStack format
1719
- if (params.$orderby) {
1720
- if (Array.isArray(params.$orderby)) {
1721
- // Handle array format ['name', '-age'] or [{ field: 'name', order: 'asc' }]
1722
- options.sort = params.$orderby.map(item => {
1723
- if (typeof item === 'string') return item;
1724
- // Handle object format { field: 'name', order: 'desc' }
1725
- const field = item.field;
1726
- const order = item.order || 'asc';
1727
- return order === 'desc' ? `-${field}` : field;
1728
- });
1729
- } else {
1730
- // Handle Record format { name: 'asc', age: 'desc' }
1731
- const sortArray = Object.entries(params.$orderby).map(([field, order]) => {
1732
- return order === 'desc' ? `-${field}` : field;
1733
- });
1734
- options.sort = sortArray;
1735
- }
1736
- }
2267
+ // Sorting — the same serializer the raw GET route uses, so the two `find()`
2268
+ // paths cannot disagree about one stored sort. The client SDK's
2269
+ // `QueryOptions.sort` accepts the shorthand string directly.
2270
+ const sort = serializeOrderBy(params.$orderby);
2271
+ if (sort) options.sort = sort;
1737
2272
 
1738
2273
  // Pagination
1739
2274
  if (params.$skip !== undefined) {
@@ -2419,6 +2954,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
2419
2954
  const measureName = params.function === 'count'
2420
2955
  ? 'count'
2421
2956
  : `${params.field}_${params.function}`;
2957
+ // The column the caller expects the value under — the raw `field`, or the
2958
+ // literal `count` when a count names no field (framework#3701). Reading
2959
+ // `params.field` directly here keyed the row `undefined` for a fieldless
2960
+ // count and deleted the `count` the server sent, so the chart plotted
2961
+ // nothing.
2962
+ const valueKey = this.aggregateValueKey(params);
2422
2963
 
2423
2964
  const payload: Record<string, unknown> = {
2424
2965
  cube: resource,
@@ -2435,6 +2976,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
2435
2976
  }
2436
2977
 
2437
2978
  const data = await this.client.analytics.query(payload);
2979
+
2438
2980
  const rawRows: any[] = Array.isArray(data) ? data
2439
2981
  : data?.rows && Array.isArray(data.rows) ? data.rows
2440
2982
  : data?.data && Array.isArray(data.data) ? data.data
@@ -2450,42 +2992,89 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
2450
2992
  const measureMissing = rawRows.length > 0 && rawRows.every((row: any) => {
2451
2993
  if (row == null) return true;
2452
2994
  if (measureName in row && row[measureName] != null) return false;
2453
- if (params.field in row && row[params.field] != null) return false;
2995
+ if (valueKey in row && row[valueKey] != null) return false;
2454
2996
  return true;
2455
2997
  });
2456
2998
  if (measureMissing) {
2457
- const result = await this.find(resource as any, params.filter ? { $filter: params.filter } as any : undefined);
2458
- const records = result.data || [];
2459
- if (records.length === 0) return [];
2460
- return this.aggregateClientSide(records, params);
2999
+ return await this.aggregateViaFind(resource, params);
2461
3000
  }
2462
3001
 
2463
- // Map measure keys back to the original field name so that consumers
2464
- // (ObjectChart, DashboardRenderer, etc.) can access values by field name.
2465
- // This includes count → field (e.g. 'count' → 'amount') to match the
2466
- // output format of aggregateClientSide() which always uses params.field.
3002
+ // Map measure keys back to the object-bound result column so consumers
3003
+ // (ObjectChart, DashboardRenderer, …) read values by the name the
3004
+ // convention promises: `field`, or `count` for a fieldless count
3005
+ // (framework#3701). This includes count → field (e.g. 'count' →
3006
+ // 'amount'), matching aggregateClientSide()'s output.
2467
3007
  return rawRows.map((row: any) => {
2468
3008
  const mapped = { ...row };
2469
- if (measureName !== params.field && measureName in mapped) {
2470
- mapped[params.field] = mapped[measureName];
3009
+ if (measureName !== valueKey && measureName in mapped) {
3010
+ mapped[valueKey] = mapped[measureName];
2471
3011
  delete mapped[measureName];
2472
3012
  }
2473
3013
  return mapped;
2474
3014
  });
2475
- } catch {
2476
- // If the analytics endpoint is not available, fall back to
2477
- // find() + client-side aggregation. Crucially, forward the same
2478
- // filter so the fallback aggregates over the SAME row set the
2479
- // server-side analytics query would have — otherwise the KPI
2480
- // silently sums the whole table and lies to the user.
2481
- const result = await this.find(resource as any, params.filter ? { $filter: params.filter } as any : undefined);
2482
- const records = result.data || [];
2483
- if (records.length === 0) return [];
3015
+ } catch (e) {
3016
+ const failure = classifyAnalyticsFailure(e);
3017
+
3018
+ // The server refused OUR body — that is a defect in this adapter's
3019
+ // request, not a deployment without the capability. Answering it with
3020
+ // the client-side path would produce plausible numbers and bury the
3021
+ // contract violation, the misdirection framework#3878 documented.
3022
+ if (failure.kind === 'rejected') {
3023
+ throw new AnalyticsQueryRejectedError(failure.message, failure.code);
3024
+ }
3025
+
3026
+ // The capability is absent (404 — framework#4019 stops mounting the
3027
+ // routes when no analytics service is registered — or 501). Say so ONCE
3028
+ // so an operator sees a missing capability rather than charts that
3029
+ // quietly read from a slower path forever.
3030
+ if (failure.kind === 'not-installed') {
3031
+ this.warnAnalyticsCapabilityOnce(failure.message);
3032
+ }
2484
3033
 
2485
- return this.aggregateClientSide(records, params);
3034
+ // Degrade to find() + client-side aggregation. `aggregateViaFind`
3035
+ // forwards the same filter, so the fallback aggregates over the SAME row
3036
+ // set the server-side query would have — and `find()` is server-scoped,
3037
+ // so RLS still applies.
3038
+ return await this.aggregateViaFind(resource, params);
2486
3039
  }
2487
3040
  }
2488
3041
 
3042
+ /**
3043
+ * Client-side aggregation over a server-scoped `find()` — the fallback used
3044
+ * whenever the analytics endpoint cannot answer (capability absent, network
3045
+ * failure, or a result whose measure came back missing).
3046
+ *
3047
+ * Forwarding `params.filter` is load-bearing: without it the fallback
3048
+ * aggregates the whole table while the caller believes it applied a filter,
3049
+ * which is the "KPI silently sums everything" failure this adapter has
3050
+ * guarded against since the widget filter was threaded through.
3051
+ */
3052
+ private async aggregateViaFind(resource: string, params: any): Promise<any[]> {
3053
+ const result = await this.find(
3054
+ resource as any,
3055
+ params.filter ? ({ $filter: params.filter } as any) : undefined,
3056
+ );
3057
+ const records = result.data || [];
3058
+ if (records.length === 0) return [];
3059
+ return this.aggregateClientSide(records, params);
3060
+ }
3061
+
3062
+ /**
3063
+ * Say "the analytics capability isn't installed" ONCE per adapter, not once
3064
+ * per widget: a dashboard fans out one aggregate() per KPI, and N identical
3065
+ * console lines read like N different failures.
3066
+ */
3067
+ private warnAnalyticsCapabilityOnce(detail?: string): void {
3068
+ if (this.analyticsCapabilityWarned) return;
3069
+ this.analyticsCapabilityWarned = true;
3070
+ console.warn(
3071
+ '[OBJECTSTACKDataSource] analytics capability unavailable — aggregating client-side ' +
3072
+ 'from a scoped find(). Numbers stay correct but the semantic layer (cubes, joins, ' +
3073
+ 'server-side rollups) is off. Install @objectstack/service-analytics to enable it.' +
3074
+ (detail ? ` Server said: ${detail}` : ''),
3075
+ );
3076
+ }
3077
+
2489
3078
  /**
2490
3079
  * Run a semantic-layer `dataset` (ADR-0021) and return chart-ready rows.
2491
3080
  *
@@ -2565,6 +3154,16 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
2565
3154
  const errBody = await res.json();
2566
3155
  detail = errBody?.message || errBody?.error || JSON.stringify(errBody);
2567
3156
  } catch { /* non-JSON error body */ }
3157
+ // "The capability isn't installed" is not a stack trace to show an
3158
+ // author (framework#3891): the REST dataset route answers 501
3159
+ // NOT_IMPLEMENTED when no analytics service provides `queryDataset`, and
3160
+ // a host that doesn't mount the route at all answers 404. Both mean the
3161
+ // same thing and both get a message a UI can render verbatim; anything
3162
+ // else (a compile error like "relationship not declared in include") is
3163
+ // a real authoring error and keeps its server detail.
3164
+ if (res.status === 501 || res.status === 404) {
3165
+ throw new AnalyticsNotInstalledError('POST /analytics/dataset/query', detail || undefined);
3166
+ }
2568
3167
  throw new Error(`Dataset query failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`);
2569
3168
  }
2570
3169
 
@@ -2592,8 +3191,19 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
2592
3191
  }
2593
3192
 
2594
3193
  /** Client-side aggregation fallback */
2595
- private aggregateClientSide(records: any[], params: { field: string; function: string; groupBy: string }): any[] {
3194
+ /**
3195
+ * The result column an object-bound `aggregate` projects its value under
3196
+ * (framework#3701, `chartAggregateValueKey` in `@objectstack/spec/ui`): the
3197
+ * raw `field` name — no `sum_`-style decoration, unlike a dataset measure —
3198
+ * or the literal `count` when a count names no field.
3199
+ */
3200
+ private aggregateValueKey(params: { field?: string; function?: string }): string {
3201
+ return params.field || params.function || 'count';
3202
+ }
3203
+
3204
+ private aggregateClientSide(records: any[], params: { field?: string; function: string; groupBy: string }): any[] {
2596
3205
  const { field, function: aggFn, groupBy } = params;
3206
+ const valueKey = this.aggregateValueKey(params);
2597
3207
  const groups: Record<string, any[]> = {};
2598
3208
 
2599
3209
  for (const record of records) {
@@ -2603,7 +3213,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
2603
3213
  }
2604
3214
 
2605
3215
  return Object.entries(groups).map(([key, group]) => {
2606
- const values = group.map(r => Number(r[field]) || 0);
3216
+ const values = field ? group.map(r => Number(r[field]) || 0) : [];
2607
3217
  let result: number;
2608
3218
 
2609
3219
  switch (aggFn) {
@@ -2614,7 +3224,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
2614
3224
  case 'sum': default: result = values.reduce((a, b) => a + b, 0); break;
2615
3225
  }
2616
3226
 
2617
- return { [groupBy]: key, [field]: result };
3227
+ return { [groupBy]: key, [valueKey]: result };
2618
3228
  });
2619
3229
  }
2620
3230