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