@object-ui/data-objectstack 16.0.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/CHANGELOG.md +711 -0
- package/README.md +63 -0
- package/dist/index.cjs +1425 -949
- package/dist/index.d.cts +319 -19
- package/dist/index.d.ts +319 -19
- package/dist/index.js +1420 -949
- package/package.json +5 -4
- package/src/aggregate-capability.test.ts +177 -0
- package/src/exportDownload.test.ts +51 -3
- package/src/filter-entry-translation.test.ts +360 -0
- package/src/filter-operator-ast-parity.test.ts +71 -0
- package/src/index.ts +984 -220
- package/src/integration.ts +8 -0
- package/src/listViews.test.ts +97 -0
- package/src/metadata-client.test.ts +8 -0
- package/src/metadata-client.ts +9 -0
- package/src/onMutation.test.ts +296 -31
- 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/userState.test.ts +25 -4
- package/src/userState.ts +10 -6
- package/src/validation-error.test.ts +94 -0
package/src/index.ts
CHANGED
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client';
|
|
10
10
|
import type {
|
|
11
11
|
DataSource,
|
|
12
|
-
|
|
12
|
+
BatchTransactionOperation,
|
|
13
|
+
DataSourceMutationEvent,
|
|
13
14
|
QueryParams,
|
|
14
15
|
QueryResult,
|
|
16
|
+
GlobalSearchResult,
|
|
17
|
+
GlobalSearchHit,
|
|
15
18
|
FileUploadResult,
|
|
16
19
|
ExportDownloadRequest,
|
|
17
20
|
ImportRequestOptions,
|
|
@@ -23,13 +26,20 @@ import type {
|
|
|
23
26
|
ImportJobUndoResult,
|
|
24
27
|
ListImportJobsOptions,
|
|
25
28
|
} from '@object-ui/types';
|
|
26
|
-
import {
|
|
29
|
+
import { errorCodeIsAnyOf } from '@object-ui/types';
|
|
30
|
+
import {
|
|
31
|
+
convertFiltersToAST,
|
|
32
|
+
emulateBatchTransaction,
|
|
33
|
+
normalizeSchemaReferenceKeys,
|
|
34
|
+
} from '@object-ui/core';
|
|
27
35
|
import { MetadataCache } from './cache/MetadataCache';
|
|
36
|
+
import { MetadataClient } from './metadata-client';
|
|
28
37
|
import {
|
|
29
38
|
ObjectStackError,
|
|
30
39
|
MetadataNotFoundError,
|
|
31
40
|
BulkOperationError,
|
|
32
41
|
ConnectionError,
|
|
42
|
+
ValidationError,
|
|
33
43
|
createErrorFromResponse,
|
|
34
44
|
} from './errors';
|
|
35
45
|
|
|
@@ -38,8 +48,15 @@ import {
|
|
|
38
48
|
* (e.g. `lead.view.ts`) to the canonical operator symbols expected by the
|
|
39
49
|
* ObjectStack server's filter AST. Unknown operators fall through unchanged
|
|
40
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
|
|
41
58
|
*/
|
|
42
|
-
const FILTER_OPERATOR_ALIASES: Record<string, string> = {
|
|
59
|
+
export const FILTER_OPERATOR_ALIASES: Record<string, string> = {
|
|
43
60
|
equals: '=',
|
|
44
61
|
eq: '=',
|
|
45
62
|
'==': '=',
|
|
@@ -76,6 +93,15 @@ const FILTER_OPERATOR_ALIASES: Record<string, string> = {
|
|
|
76
93
|
isnull: 'isnull',
|
|
77
94
|
is_not_null: 'isnotnull',
|
|
78
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: '>',
|
|
79
105
|
};
|
|
80
106
|
|
|
81
107
|
function normalizeFilterOperator(op: unknown): string | null {
|
|
@@ -84,15 +110,159 @@ function normalizeFilterOperator(op: unknown): string | null {
|
|
|
84
110
|
return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
|
|
85
111
|
}
|
|
86
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
|
+
|
|
87
150
|
function objectFilterEntryToAST(entry: any): [string, string, any] | null {
|
|
88
151
|
if (!entry || typeof entry !== 'object') return null;
|
|
89
|
-
|
|
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;
|
|
90
159
|
const rawOp = entry.operator ?? entry.op ?? '=';
|
|
91
160
|
const op = normalizeFilterOperator(rawOp);
|
|
92
161
|
if (!field || !op) return null;
|
|
93
162
|
return [String(field), op, entry.value];
|
|
94
163
|
}
|
|
95
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
|
+
|
|
96
266
|
/**
|
|
97
267
|
* Translate any of the filter shapes accepted by ObjectUI into the AST format
|
|
98
268
|
* understood by the ObjectStack server's `parseFilterAST()`.
|
|
@@ -112,30 +282,68 @@ function translateFilterToAST(filter: unknown): unknown | undefined {
|
|
|
112
282
|
|
|
113
283
|
if (Array.isArray(filter)) {
|
|
114
284
|
if (filter.length === 0) return undefined;
|
|
115
|
-
|
|
116
|
-
// Object form: [{ field, operator, value }, ...]
|
|
117
|
-
const first = filter[0];
|
|
118
|
-
const isObjectForm = filter.length > 0
|
|
119
|
-
&& typeof first === 'object'
|
|
120
|
-
&& first !== null
|
|
121
|
-
&& !Array.isArray(first)
|
|
122
|
-
&& (first as any).field !== undefined;
|
|
123
|
-
if (isObjectForm) {
|
|
124
|
-
const tuples = (filter as any[])
|
|
125
|
-
.map(entry => objectFilterEntryToAST(entry))
|
|
126
|
-
.filter((t): t is [string, string, any] => t !== null);
|
|
127
|
-
if (tuples.length === 0) return undefined;
|
|
128
|
-
if (tuples.length === 1) return tuples[0];
|
|
129
|
-
return ['and', ...tuples];
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Already AST — pass through.
|
|
133
|
-
return filter;
|
|
285
|
+
return translateFilterArray(filter);
|
|
134
286
|
}
|
|
135
287
|
|
|
136
288
|
if (typeof filter === 'object') {
|
|
137
289
|
if (Object.keys(filter as Record<string, unknown>).length === 0) return undefined;
|
|
138
|
-
return
|
|
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;
|
|
139
347
|
}
|
|
140
348
|
|
|
141
349
|
return undefined;
|
|
@@ -173,6 +381,44 @@ export function clearSharedDiscoveryCache(): void {
|
|
|
173
381
|
discoveryCache.clear();
|
|
174
382
|
}
|
|
175
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Read the cross-object atomic-batch capability from a `discovery` document
|
|
386
|
+
* (framework #3298 / objectui #2693). The server advertises it hierarchically
|
|
387
|
+
* under `capabilities.transactionalBatch.enabled`; the published
|
|
388
|
+
* `@objectstack/client` also accepts the flat `capabilities.transactionalBatch:
|
|
389
|
+
* boolean` form and normalizes the two — mirror that here so the adapter reads
|
|
390
|
+
* the same bit regardless of which shape reaches it.
|
|
391
|
+
*
|
|
392
|
+
* Returns:
|
|
393
|
+
* - `true` — the backend GUARANTEES an atomic `/batch` (declared === enforced,
|
|
394
|
+
* i.e. the route is mounted AND the runtime can honour a transaction): the
|
|
395
|
+
* client may drop its non-atomic fallback and treat any batch failure as a
|
|
396
|
+
* real error.
|
|
397
|
+
* - `false` — the backend explicitly does NOT (route absent, or a runtime that
|
|
398
|
+
* can't open a transaction).
|
|
399
|
+
* - `undefined` — the capability is absent, i.e. the backend predates #3298;
|
|
400
|
+
* the caller must keep the legacy runtime-probe fallback (we can't tell
|
|
401
|
+
* whether `/batch` exists without trying it).
|
|
402
|
+
*/
|
|
403
|
+
export function readTransactionalBatchCapability(
|
|
404
|
+
discovery: unknown,
|
|
405
|
+
): boolean | undefined {
|
|
406
|
+
const caps = (discovery as { capabilities?: unknown } | null | undefined)?.capabilities;
|
|
407
|
+
if (!caps || typeof caps !== 'object') return undefined;
|
|
408
|
+
const value = (caps as Record<string, unknown>).transactionalBatch;
|
|
409
|
+
// Flat form: `{ transactionalBatch: true }`.
|
|
410
|
+
if (typeof value === 'boolean') return value;
|
|
411
|
+
// Hierarchical form: `{ transactionalBatch: { enabled: true } }`.
|
|
412
|
+
if (
|
|
413
|
+
value &&
|
|
414
|
+
typeof value === 'object' &&
|
|
415
|
+
typeof (value as { enabled?: unknown }).enabled === 'boolean'
|
|
416
|
+
) {
|
|
417
|
+
return (value as { enabled: boolean }).enabled;
|
|
418
|
+
}
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
|
|
176
422
|
/**
|
|
177
423
|
* Detect "missing resource" errors regardless of where they originate.
|
|
178
424
|
*
|
|
@@ -189,7 +435,108 @@ export function is404Error(error: unknown): boolean {
|
|
|
189
435
|
return true;
|
|
190
436
|
}
|
|
191
437
|
const code = typeof err.code === 'string' ? err.code : '';
|
|
192
|
-
return code
|
|
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 };
|
|
193
540
|
}
|
|
194
541
|
|
|
195
542
|
/**
|
|
@@ -235,17 +582,55 @@ export function isConcurrentUpdateError(error: unknown): error is ConcurrentUpda
|
|
|
235
582
|
}
|
|
236
583
|
|
|
237
584
|
/**
|
|
238
|
-
* Convert any error thrown by the upstream client into a typed
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
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.
|
|
242
594
|
*/
|
|
243
595
|
export function normaliseClientError(error: unknown): unknown {
|
|
244
596
|
if (!error || typeof error !== 'object') return error;
|
|
245
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
|
+
|
|
246
632
|
if (e.code !== 'CONCURRENT_UPDATE' && e.httpStatus !== 409) return error;
|
|
247
633
|
if (e.code !== 'CONCURRENT_UPDATE') return error;
|
|
248
|
-
const details = (e.details ?? {}) as Record<string, unknown>;
|
|
249
634
|
return new ConcurrentUpdateError({
|
|
250
635
|
currentVersion: typeof details.currentVersion === 'string' ? details.currentVersion : null,
|
|
251
636
|
currentRecord: details.currentRecord ?? null,
|
|
@@ -271,7 +656,7 @@ function createQuietHttpLogger(): any {
|
|
|
271
656
|
const errBody = meta.error;
|
|
272
657
|
if (errBody && typeof errBody === 'object') {
|
|
273
658
|
const code = (errBody as Record<string, unknown>).code;
|
|
274
|
-
if (code
|
|
659
|
+
if (errorCodeIsAnyOf({ code }, ['OBJECT_NOT_FOUND', 'RECORD_NOT_FOUND'])) return true;
|
|
275
660
|
}
|
|
276
661
|
return false;
|
|
277
662
|
};
|
|
@@ -333,6 +718,36 @@ export type ConnectionStateListener = (event: ConnectionStateEvent) => void;
|
|
|
333
718
|
*/
|
|
334
719
|
export type BatchProgressListener = (event: BatchProgressEvent) => void;
|
|
335
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
|
+
|
|
336
751
|
// Re-export FileUploadResult from types for consumers
|
|
337
752
|
export type { FileUploadResult } from '@object-ui/types';
|
|
338
753
|
|
|
@@ -393,6 +808,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
393
808
|
private reconnectAttempts: number = 0;
|
|
394
809
|
private baseUrl: string;
|
|
395
810
|
private token?: string;
|
|
811
|
+
/** One "analytics capability is missing" console line per adapter, not per widget. */
|
|
812
|
+
private analyticsCapabilityWarned = false;
|
|
396
813
|
private fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
397
814
|
// In-flight find() requests keyed by resource + serialized params.
|
|
398
815
|
// Coalesces concurrent identical reads (e.g. React StrictMode double-mount,
|
|
@@ -404,11 +821,31 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
404
821
|
// so optional collections like sys_presence don't hammer the server with
|
|
405
822
|
// failing requests on every record open / panel render.
|
|
406
823
|
private missingResources = new Set<string>();
|
|
824
|
+
// Set once the server has told us it can't do a cross-object transactional
|
|
825
|
+
// batch (the client SDK's data.batchTransaction threw HTTP 404/405/501). After
|
|
826
|
+
// that, batchTransaction skips the SDK call and serves every call via the
|
|
827
|
+
// client-side emulation — the non-atomic fallback lives HERE, isolated to the
|
|
828
|
+
// one adapter that has to cope with a backend lacking server atomicity (#2679).
|
|
829
|
+
private batchUnsupported = false;
|
|
830
|
+
// The server's declared cross-object atomic-batch capability, read from
|
|
831
|
+
// discovery at connect() (framework #3298 / objectui #2693). `true` → the
|
|
832
|
+
// backend GUARANTEES an atomic `/batch`, so batchTransaction trusts it and
|
|
833
|
+
// never degrades to the non-atomic emulation (any failure surfaces as a real
|
|
834
|
+
// error). `false` or `undefined` (capability absent → backend predates #3298)
|
|
835
|
+
// → keep the legacy runtime-probe + emulation fallback so a save is still
|
|
836
|
+
// possible; dropping it there would turn "saves, less safe" into "no save
|
|
837
|
+
// path" on older backends (#2679 compatibility constraint).
|
|
838
|
+
private atomicBatchCapability: boolean | undefined;
|
|
407
839
|
// Subscribers registered via onMutation(). Emitted after each successful
|
|
408
840
|
// create/update/delete so data-bound views (ListView, ObjectView, kanban,
|
|
409
841
|
// calendar) auto-refresh — the interface ListView relies on to reflect
|
|
410
842
|
// inline-edit "Save All" writes without a manual reload.
|
|
411
|
-
private mutationListeners = new Set<(event:
|
|
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>();
|
|
412
849
|
|
|
413
850
|
constructor(config: {
|
|
414
851
|
baseUrl: string;
|
|
@@ -479,6 +916,11 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
479
916
|
// helpers continue to work without a redundant fetch.
|
|
480
917
|
(this.client as unknown as { discoveryInfo?: unknown }).discoveryInfo = data;
|
|
481
918
|
|
|
919
|
+
// Record the declared cross-object atomic-batch capability (#3298) so
|
|
920
|
+
// batchTransaction can decide declaratively at call time whether it may
|
|
921
|
+
// trust server atomicity instead of runtime-probing 404/405/501.
|
|
922
|
+
this.atomicBatchCapability = readTransactionalBatchCapability(data);
|
|
923
|
+
|
|
482
924
|
this.connected = true;
|
|
483
925
|
this.reconnectAttempts = 0;
|
|
484
926
|
this.setConnectionState('connected');
|
|
@@ -663,6 +1105,103 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
663
1105
|
return promise;
|
|
664
1106
|
}
|
|
665
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
|
+
|
|
666
1205
|
/**
|
|
667
1206
|
* Find a single record by ID.
|
|
668
1207
|
*/
|
|
@@ -716,7 +1255,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
716
1255
|
* Notify all mutation subscribers. A throwing listener must not break the
|
|
717
1256
|
* mutation or starve the other subscribers, so each is isolated.
|
|
718
1257
|
*/
|
|
719
|
-
private emitMutation(event:
|
|
1258
|
+
private emitMutation(event: DataSourceMutationEvent<T>): void {
|
|
720
1259
|
for (const listener of this.mutationListeners) {
|
|
721
1260
|
try {
|
|
722
1261
|
listener(event);
|
|
@@ -732,18 +1271,110 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
732
1271
|
* mutation (e.g. inline-edit "Save All", which writes through `update` and
|
|
733
1272
|
* must repaint the list without a manual reload).
|
|
734
1273
|
*/
|
|
735
|
-
onMutation(callback: (event:
|
|
1274
|
+
onMutation(callback: (event: DataSourceMutationEvent<T>) => void): () => void {
|
|
736
1275
|
this.mutationListeners.add(callback);
|
|
737
1276
|
return () => {
|
|
738
1277
|
this.mutationListeners.delete(callback);
|
|
739
1278
|
};
|
|
740
1279
|
}
|
|
741
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
|
+
|
|
742
1364
|
async create(resource: string, data: Partial<T>): Promise<T> {
|
|
743
1365
|
await this.connect();
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
+
}
|
|
747
1378
|
}
|
|
748
1379
|
|
|
749
1380
|
/**
|
|
@@ -772,6 +1403,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
772
1403
|
opts?.ifMatch ? { ifMatch: opts.ifMatch } : undefined,
|
|
773
1404
|
);
|
|
774
1405
|
this.emitMutation({ type: 'update', resource, id, record: { ...result.record } });
|
|
1406
|
+
this.notifyDroppedFields('update', resource, result, id);
|
|
775
1407
|
return result.record;
|
|
776
1408
|
} catch (err) {
|
|
777
1409
|
throw normaliseClientError(err);
|
|
@@ -927,42 +1559,120 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
927
1559
|
* @returns Promise resolving to array of results
|
|
928
1560
|
*/
|
|
929
1561
|
/**
|
|
930
|
-
* Cross-object transactional batch (ObjectStack #1604
|
|
931
|
-
* in ONE server transaction — commit all or roll back
|
|
932
|
-
* `{ $ref: <earlier op index> }` resolves to that op's
|
|
933
|
-
* can reference its parent created earlier in the same
|
|
1562
|
+
* Cross-object transactional batch (ObjectStack #1604 / ADR-0034 item 4).
|
|
1563
|
+
* Runs the operations in ONE server transaction — commit all or roll back
|
|
1564
|
+
* all. A field value of `{ $ref: <earlier op index> }` resolves to that op's
|
|
1565
|
+
* created id, so a child can reference its parent created earlier in the same
|
|
1566
|
+
* batch (master-detail).
|
|
1567
|
+
*
|
|
1568
|
+
* Transport: the published `@objectstack/client` SDK method
|
|
1569
|
+
* `data.batchTransaction` (framework #3271; shipped since client v16, our
|
|
1570
|
+
* dependency floor). Per AGENTS.md §7 data always flows through the client —
|
|
1571
|
+
* never a hand-rolled `fetch('/api/v1/batch')`.
|
|
1572
|
+
*
|
|
1573
|
+
* Fallback decision — declarative capability negotiation (framework #3298 /
|
|
1574
|
+
* objectui #2693). At connect() we read `capabilities.transactionalBatch`
|
|
1575
|
+
* from discovery:
|
|
1576
|
+
* - Declared `true` → the backend GUARANTEES atomicity (declared ===
|
|
1577
|
+
* enforced). We TRUST it: any batch failure — including 404/405/501 —
|
|
1578
|
+
* surfaces as a real error. No non-atomic client-side compensation. This
|
|
1579
|
+
* is the path modern backends take.
|
|
1580
|
+
* - Declared `false`, or ABSENT (backend predates #3298) → we can't rely on
|
|
1581
|
+
* server atomicity, so we keep the legacy behaviour: on 404/405 (no
|
|
1582
|
+
* endpoint) or 501 (runtime without transactions) degrade to the
|
|
1583
|
+
* client-side, NON-atomic {@link emulateBatchTransaction} so a save is
|
|
1584
|
+
* still possible. Removing that here would regress older backends from
|
|
1585
|
+
* "saves, less safe" to "no save path" (#2679 compatibility constraint).
|
|
1586
|
+
* The non-atomic fallback stays isolated to THIS adapter.
|
|
934
1587
|
*/
|
|
935
1588
|
async batchTransaction(
|
|
936
|
-
operations:
|
|
1589
|
+
operations: BatchTransactionOperation[],
|
|
937
1590
|
): Promise<{ results: any[] }> {
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1591
|
+
// Ensure discovery (and thus the #3298 capability) is loaded so the
|
|
1592
|
+
// decision below is declarative, not "fire a batch and read the status".
|
|
1593
|
+
await this.connect();
|
|
1594
|
+
|
|
1595
|
+
// When the backend declares atomic batch support we never degrade: a
|
|
1596
|
+
// failure is a real error, not a cue to fall back. Otherwise (declared
|
|
1597
|
+
// false, or capability absent on a pre-#3298 backend) the emulation
|
|
1598
|
+
// fallback below stays active.
|
|
1599
|
+
const guaranteed = this.atomicBatchCapability === true;
|
|
1600
|
+
|
|
1601
|
+
// Already degraded on a non-declaring backend — skip the SDK call.
|
|
1602
|
+
// (Unreachable once `guaranteed`: that path never sets `batchUnsupported`.)
|
|
1603
|
+
if (!guaranteed && this.batchUnsupported) {
|
|
1604
|
+
return emulateBatchTransaction(this, operations);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
try {
|
|
1608
|
+
// Typed SDK method — guaranteed present by the `@objectstack/client@^16`
|
|
1609
|
+
// dependency floor (framework #3271). No hand-rolled POST /api/v1/batch.
|
|
1610
|
+
const payload = await this.client.data.batchTransaction(operations);
|
|
1611
|
+
this.emitBatchMutations(operations, payload?.results);
|
|
1612
|
+
this.notifyBatchDroppedFields(operations, payload);
|
|
1613
|
+
return payload;
|
|
1614
|
+
} catch (err) {
|
|
1615
|
+
// On a non-declaring backend, endpoint missing (404/405) or a runtime that
|
|
1616
|
+
// can't do transactions (the framework rest-server answers 501
|
|
1617
|
+
// "Transactional batch not supported by this runtime") → degrade to the
|
|
1618
|
+
// non-atomic client emulation so the save still goes through. When the
|
|
1619
|
+
// backend DECLARED support (`guaranteed`), even these are hard errors — a
|
|
1620
|
+
// server that advertised the capability must honour it. Every other status
|
|
1621
|
+
// (400 validation, 401/403 auth, 409 conflict, 500 fault) is a real error
|
|
1622
|
+
// the caller must see — never silently retried.
|
|
1623
|
+
const status = this.errorStatusOf(err);
|
|
1624
|
+
if (!guaranteed && this.batchStatusUnsupported(status)) {
|
|
1625
|
+
return this.fallbackToEmulation(operations, status);
|
|
1626
|
+
}
|
|
1627
|
+
throw err;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/** True for statuses that mean "this backend can't do a transactional batch". */
|
|
1632
|
+
private batchStatusUnsupported(status: number | undefined): boolean {
|
|
1633
|
+
return status === 404 || status === 405 || status === 501;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
/** Best-effort HTTP status extraction from a thrown SDK/client error. */
|
|
1637
|
+
private errorStatusOf(err: unknown): number | undefined {
|
|
1638
|
+
if (!err || typeof err !== 'object') return undefined;
|
|
1639
|
+
const e = err as Record<string, unknown>;
|
|
1640
|
+
const s = e.httpStatus ?? e.status ?? e.statusCode;
|
|
1641
|
+
return typeof s === 'number' ? s : undefined;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/** Mark the endpoint unsupported (warn once) and serve via emulation. */
|
|
1645
|
+
private fallbackToEmulation(
|
|
1646
|
+
operations: BatchTransactionOperation[],
|
|
1647
|
+
status: number | undefined,
|
|
1648
|
+
): Promise<{ results: any[] }> {
|
|
1649
|
+
if (!this.batchUnsupported) {
|
|
1650
|
+
this.batchUnsupported = true;
|
|
1651
|
+
console.warn(
|
|
1652
|
+
`ObjectStackAdapter: POST /api/v1/batch unavailable (HTTP ${status ?? '?'}) — ` +
|
|
1653
|
+
'falling back to non-atomic client-side batch emulation. Cross-object ' +
|
|
1654
|
+
'saves on this backend are best-effort, not transactional.',
|
|
955
1655
|
);
|
|
956
1656
|
}
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1657
|
+
return emulateBatchTransaction(this, operations);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/**
|
|
1661
|
+
* Emit one DataSourceMutationEvent per committed operation so the invalidation bus
|
|
1662
|
+
* (#2269) sees writes that went through /batch exactly like single
|
|
1663
|
+
* create/update/delete calls — master-detail ModalForm saves otherwise leave
|
|
1664
|
+
* related lists and count badges stale (#2582). `results` is index-aligned
|
|
1665
|
+
* with `operations`; creates take id/record from the server echo.
|
|
1666
|
+
*
|
|
1667
|
+
* Only called on the server-committed paths. The emulation branch drives the
|
|
1668
|
+
* adapter's own create/update/delete primitives, which already emit — so it
|
|
1669
|
+
* must NOT be routed through here, or events would double-fire.
|
|
1670
|
+
*/
|
|
1671
|
+
private emitBatchMutations(
|
|
1672
|
+
operations: BatchTransactionOperation[],
|
|
1673
|
+
rawResults: unknown,
|
|
1674
|
+
): void {
|
|
1675
|
+
const results = Array.isArray(rawResults) ? rawResults : [];
|
|
966
1676
|
operations.forEach((op, i) => {
|
|
967
1677
|
const action = op.action ?? 'create';
|
|
968
1678
|
const echo = results[i];
|
|
@@ -974,7 +1684,6 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
974
1684
|
this.emitMutation({ type: 'delete', resource: op.object, id: op.id });
|
|
975
1685
|
}
|
|
976
1686
|
});
|
|
977
|
-
return payload;
|
|
978
1687
|
}
|
|
979
1688
|
|
|
980
1689
|
async bulk(resource: string, operation: 'create' | 'update' | 'delete', data: Partial<T>[]): Promise<T[]> {
|
|
@@ -1397,22 +2106,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1397
2106
|
}
|
|
1398
2107
|
|
|
1399
2108
|
// Sorting
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
const sortStr = params.$orderby.map(item => {
|
|
1403
|
-
if (typeof item === 'string') return item;
|
|
1404
|
-
const field = item.field;
|
|
1405
|
-
const order = item.order || 'asc';
|
|
1406
|
-
return order === 'desc' ? `-${field}` : field;
|
|
1407
|
-
}).join(',');
|
|
1408
|
-
queryParams.set('sort', sortStr);
|
|
1409
|
-
} else {
|
|
1410
|
-
const sortStr = Object.entries(params.$orderby)
|
|
1411
|
-
.map(([field, order]) => order === 'desc' ? `-${field}` : field)
|
|
1412
|
-
.join(',');
|
|
1413
|
-
queryParams.set('sort', sortStr);
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
2109
|
+
const sortStr = serializeOrderBy(params.$orderby);
|
|
2110
|
+
if (sortStr) queryParams.set('sort', sortStr);
|
|
1416
2111
|
|
|
1417
2112
|
// Filter — translate ViewFilterRule[] (`[{field, operator, value}]`)
|
|
1418
2113
|
// and other shapes into AST tuples the server understands. Without this,
|
|
@@ -1497,6 +2192,16 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1497
2192
|
queryParams.set('filter', JSON.stringify(translated));
|
|
1498
2193
|
}
|
|
1499
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
|
+
}
|
|
1500
2205
|
|
|
1501
2206
|
const baseUrl = this.baseUrl.replace(/\/$/, '');
|
|
1502
2207
|
// Avoid doubling /api/v1 if baseUrl already includes the version suffix.
|
|
@@ -1549,51 +2254,21 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1549
2254
|
// entry into the AST tuple shape and map human-readable
|
|
1550
2255
|
// operator names (`greater_than_or_equal`, `in`, `contains`,
|
|
1551
2256
|
// …) to the canonical symbols the server understands.
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
if (isObjectForm) {
|
|
1557
|
-
const tuples = (params.$filter as any[])
|
|
1558
|
-
.map(entry => objectFilterEntryToAST(entry))
|
|
1559
|
-
.filter((t): t is [string, string, any] => t !== null);
|
|
1560
|
-
if (tuples.length === 0) {
|
|
1561
|
-
// All entries were unrecognized — drop the filter rather than
|
|
1562
|
-
// sending a malformed array.
|
|
1563
|
-
} else if (tuples.length === 1) {
|
|
1564
|
-
options.filters = tuples[0];
|
|
1565
|
-
} else {
|
|
1566
|
-
options.filters = ['and', ...tuples];
|
|
1567
|
-
}
|
|
1568
|
-
} else {
|
|
1569
|
-
// Already in AST format
|
|
1570
|
-
options.filters = params.$filter;
|
|
1571
|
-
}
|
|
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);
|
|
1572
2261
|
} else {
|
|
1573
2262
|
options.filters = convertFiltersToAST(params.$filter);
|
|
1574
2263
|
}
|
|
1575
2264
|
}
|
|
1576
2265
|
}
|
|
1577
2266
|
|
|
1578
|
-
// Sorting
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
if (typeof item === 'string') return item;
|
|
1584
|
-
// Handle object format { field: 'name', order: 'desc' }
|
|
1585
|
-
const field = item.field;
|
|
1586
|
-
const order = item.order || 'asc';
|
|
1587
|
-
return order === 'desc' ? `-${field}` : field;
|
|
1588
|
-
});
|
|
1589
|
-
} else {
|
|
1590
|
-
// Handle Record format { name: 'asc', age: 'desc' }
|
|
1591
|
-
const sortArray = Object.entries(params.$orderby).map(([field, order]) => {
|
|
1592
|
-
return order === 'desc' ? `-${field}` : field;
|
|
1593
|
-
});
|
|
1594
|
-
options.sort = sortArray;
|
|
1595
|
-
}
|
|
1596
|
-
}
|
|
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;
|
|
1597
2272
|
|
|
1598
2273
|
// Pagination
|
|
1599
2274
|
if (params.$skip !== undefined) {
|
|
@@ -1930,32 +2605,25 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1930
2605
|
config: Record<string, any>
|
|
1931
2606
|
): Promise<Record<string, any> | void> {
|
|
1932
2607
|
await this.connect();
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
return result ?? undefined;
|
|
1953
|
-
} catch (err) {
|
|
1954
|
-
// Surface the error so the caller can decide whether to toast/log;
|
|
1955
|
-
// we don't swallow it here because persistence failures are
|
|
1956
|
-
// operationally meaningful (unlike read fallbacks).
|
|
1957
|
-
throw err;
|
|
1958
|
-
}
|
|
2608
|
+
// ADR-0005 metadata customization overlay: persist views under
|
|
2609
|
+
// `type='view'` (NOT `type=<objectName>` — that was a pre-overlay
|
|
2610
|
+
// misuse that hit `/api/v1/meta/<objectName>/<viewId>`, which the
|
|
2611
|
+
// server never wired). The view's `data.object` field is what
|
|
2612
|
+
// associates it back to the object on read.
|
|
2613
|
+
const merged = { ...(config || {}), object: (config as any)?.object || objectName, name: viewId };
|
|
2614
|
+
const result: any = await this.client.meta.saveItem(
|
|
2615
|
+
'view',
|
|
2616
|
+
viewId,
|
|
2617
|
+
merged
|
|
2618
|
+
);
|
|
2619
|
+
// Invalidate cached read so next getView reflects the change
|
|
2620
|
+
const cacheKey = `view:${objectName}:${viewId}`;
|
|
2621
|
+
this.metadataCache.invalidate?.(cacheKey);
|
|
2622
|
+
// Also invalidate the batch override map so listViewOverrides re-fetches
|
|
2623
|
+
this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
|
|
2624
|
+
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
2625
|
+
if (result && result.item) return result.item;
|
|
2626
|
+
return result ?? undefined;
|
|
1959
2627
|
}
|
|
1960
2628
|
|
|
1961
2629
|
/**
|
|
@@ -1968,13 +2636,31 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1968
2636
|
* Filters by `data.object === objectName` (or top-level `object`)
|
|
1969
2637
|
* client-side because the metadata index is name-only, not field-typed.
|
|
1970
2638
|
*/
|
|
1971
|
-
async listViews(
|
|
2639
|
+
async listViews(
|
|
2640
|
+
objectName: string,
|
|
2641
|
+
options?: { previewDrafts?: boolean },
|
|
2642
|
+
): Promise<any[]> {
|
|
1972
2643
|
await this.connect();
|
|
1973
2644
|
try {
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
2645
|
+
let items: any[];
|
|
2646
|
+
if (options?.previewDrafts) {
|
|
2647
|
+
// ADR-0037 + #2767 (P2/P3): a SINGLE `?preview=draft` request already
|
|
2648
|
+
// returns the active+draft OVERLAID list — draft wins by name,
|
|
2649
|
+
// draft-only views surface, each draft tagged `_draft: true`. So we
|
|
2650
|
+
// REPLACE the published list wholesale (never fetch both and append,
|
|
2651
|
+
// which double-lists a draft that edits a published view). Route it
|
|
2652
|
+
// through `MetadataClient` rather than a hand-rolled fetch so the
|
|
2653
|
+
// metadata route + any environment scoping stay in one place.
|
|
2654
|
+
const draftItems = await this.metadataClient()
|
|
2655
|
+
.withPreviewDrafts(true)
|
|
2656
|
+
.list<any>('view');
|
|
2657
|
+
items = Array.isArray(draftItems) ? draftItems : [];
|
|
2658
|
+
} else {
|
|
2659
|
+
const result: any = await this.client.meta.getItems('view');
|
|
2660
|
+
items = Array.isArray(result?.items)
|
|
2661
|
+
? result.items
|
|
2662
|
+
: Array.isArray(result) ? result : [];
|
|
2663
|
+
}
|
|
1978
2664
|
// This feeds the list-view switcher (ViewTabBar), so it must return
|
|
1979
2665
|
// LIST-family views only. The backend now exposes each view as an
|
|
1980
2666
|
// independent ViewItem carrying a `viewKind` discriminant (ADR-0017);
|
|
@@ -1993,6 +2679,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1993
2679
|
return !(viewKind && FORM_FAMILY.has(viewKind));
|
|
1994
2680
|
}).map((v: any) => {
|
|
1995
2681
|
const spec = v.list ?? v;
|
|
2682
|
+
// Preserve the draft provenance flag so the switcher can badge an
|
|
2683
|
+
// unpublished view (ADR-0037). The overlay tags the item, not its
|
|
2684
|
+
// nested spec, so read from either.
|
|
2685
|
+
const isDraft = v._draft === true || spec?._draft === true;
|
|
1996
2686
|
// Canonical ViewItem (ADR-0017) carries its body under `config`;
|
|
1997
2687
|
// the display `type` (grid/kanban/gallery/…) lives at `config.type`,
|
|
1998
2688
|
// and only the list/form *family* sits at the top level (`viewKind`).
|
|
@@ -2008,9 +2698,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2008
2698
|
name: spec.name ?? spec.config.name,
|
|
2009
2699
|
label: spec.label ?? spec.config.label,
|
|
2010
2700
|
isDefault: !!spec.isDefault,
|
|
2701
|
+
...(isDraft ? { _draft: true } : {}),
|
|
2011
2702
|
};
|
|
2012
2703
|
}
|
|
2013
|
-
return spec;
|
|
2704
|
+
return isDraft ? { ...spec, _draft: true } : spec;
|
|
2014
2705
|
});
|
|
2015
2706
|
} catch (err) {
|
|
2016
2707
|
console.warn('[OBJECTSTACKDataSource] listViews failed:', err);
|
|
@@ -2018,6 +2709,20 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2018
2709
|
}
|
|
2019
2710
|
}
|
|
2020
2711
|
|
|
2712
|
+
/**
|
|
2713
|
+
* Build a {@link MetadataClient} bound to this adapter's server + auth. Used
|
|
2714
|
+
* by draft-aware reads (`listViews({ previewDrafts })`) so the `/meta` route,
|
|
2715
|
+
* `?preview=draft` flag, and environment scoping live in the SDK rather than
|
|
2716
|
+
* being hand-assembled at each call site (#2767 P3).
|
|
2717
|
+
*/
|
|
2718
|
+
private metadataClient(): MetadataClient {
|
|
2719
|
+
return new MetadataClient({
|
|
2720
|
+
baseUrl: this.baseUrl,
|
|
2721
|
+
fetch: this.fetchImpl,
|
|
2722
|
+
...(this.token ? { headers: { Authorization: `Bearer ${this.token}` } } : {}),
|
|
2723
|
+
});
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2021
2726
|
/**
|
|
2022
2727
|
* List registered import `mapping` artifacts targeting a given object
|
|
2023
2728
|
* (framework #2611). Reads the `mapping` metadata kind via the overlay API
|
|
@@ -2075,14 +2780,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2075
2780
|
object: spec?.object || objectName,
|
|
2076
2781
|
data: spec?.data || { provider: 'object', object: objectName },
|
|
2077
2782
|
};
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
return fullSpec;
|
|
2083
|
-
} catch (err) {
|
|
2084
|
-
throw err;
|
|
2085
|
-
}
|
|
2783
|
+
const result: any = await this.client.meta.saveItem('view', name, fullSpec);
|
|
2784
|
+
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
2785
|
+
if (result && result.item) return result.item;
|
|
2786
|
+
return fullSpec;
|
|
2086
2787
|
}
|
|
2087
2788
|
|
|
2088
2789
|
/**
|
|
@@ -2112,15 +2813,11 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2112
2813
|
name: viewName,
|
|
2113
2814
|
object: current?.object || (current as any)?.data?.object || objectName,
|
|
2114
2815
|
};
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
return merged;
|
|
2121
|
-
} catch (err) {
|
|
2122
|
-
throw err;
|
|
2123
|
-
}
|
|
2816
|
+
const result: any = await this.client.meta.saveItem('view', viewName, merged);
|
|
2817
|
+
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
2818
|
+
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
|
|
2819
|
+
if (result && result.item) return result.item;
|
|
2820
|
+
return merged;
|
|
2124
2821
|
}
|
|
2125
2822
|
|
|
2126
2823
|
/**
|
|
@@ -2133,14 +2830,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2133
2830
|
viewName: string,
|
|
2134
2831
|
): Promise<{ deleted: boolean }> {
|
|
2135
2832
|
await this.connect();
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
return { deleted: !!(result?.deleted ?? result?.reset ?? true) };
|
|
2141
|
-
} catch (err) {
|
|
2142
|
-
throw err;
|
|
2143
|
-
}
|
|
2833
|
+
const result: any = await this.client.meta.deleteItem('view', viewName);
|
|
2834
|
+
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
2835
|
+
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
|
|
2836
|
+
return { deleted: !!(result?.deleted ?? result?.reset ?? true) };
|
|
2144
2837
|
}
|
|
2145
2838
|
|
|
2146
2839
|
|
|
@@ -2207,21 +2900,17 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2207
2900
|
schema: Record<string, any>
|
|
2208
2901
|
): Promise<Record<string, any> | void> {
|
|
2209
2902
|
await this.connect();
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
return result ?? undefined;
|
|
2222
|
-
} catch (err) {
|
|
2223
|
-
throw err;
|
|
2224
|
-
}
|
|
2903
|
+
const result: any = await this.client.meta.saveItem(
|
|
2904
|
+
'dashboard',
|
|
2905
|
+
dashboardName,
|
|
2906
|
+
schema
|
|
2907
|
+
);
|
|
2908
|
+
// Invalidate dashboards list and any cached dashboard read so the
|
|
2909
|
+
// next render reflects the change.
|
|
2910
|
+
this.metadataCache.invalidate?.('dashboards');
|
|
2911
|
+
this.metadataCache.invalidate?.(`dashboard:${dashboardName}`);
|
|
2912
|
+
if (result && result.item) return result.item;
|
|
2913
|
+
return result ?? undefined;
|
|
2225
2914
|
}
|
|
2226
2915
|
|
|
2227
2916
|
/**
|
|
@@ -2265,6 +2954,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2265
2954
|
const measureName = params.function === 'count'
|
|
2266
2955
|
? 'count'
|
|
2267
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);
|
|
2268
2963
|
|
|
2269
2964
|
const payload: Record<string, unknown> = {
|
|
2270
2965
|
cube: resource,
|
|
@@ -2281,6 +2976,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2281
2976
|
}
|
|
2282
2977
|
|
|
2283
2978
|
const data = await this.client.analytics.query(payload);
|
|
2979
|
+
|
|
2284
2980
|
const rawRows: any[] = Array.isArray(data) ? data
|
|
2285
2981
|
: data?.rows && Array.isArray(data.rows) ? data.rows
|
|
2286
2982
|
: data?.data && Array.isArray(data.data) ? data.data
|
|
@@ -2296,42 +2992,89 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2296
2992
|
const measureMissing = rawRows.length > 0 && rawRows.every((row: any) => {
|
|
2297
2993
|
if (row == null) return true;
|
|
2298
2994
|
if (measureName in row && row[measureName] != null) return false;
|
|
2299
|
-
if (
|
|
2995
|
+
if (valueKey in row && row[valueKey] != null) return false;
|
|
2300
2996
|
return true;
|
|
2301
2997
|
});
|
|
2302
2998
|
if (measureMissing) {
|
|
2303
|
-
|
|
2304
|
-
const records = result.data || [];
|
|
2305
|
-
if (records.length === 0) return [];
|
|
2306
|
-
return this.aggregateClientSide(records, params);
|
|
2999
|
+
return await this.aggregateViaFind(resource, params);
|
|
2307
3000
|
}
|
|
2308
3001
|
|
|
2309
|
-
// Map measure keys back to the
|
|
2310
|
-
// (ObjectChart, DashboardRenderer,
|
|
2311
|
-
//
|
|
2312
|
-
//
|
|
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.
|
|
2313
3007
|
return rawRows.map((row: any) => {
|
|
2314
3008
|
const mapped = { ...row };
|
|
2315
|
-
if (measureName !==
|
|
2316
|
-
mapped[
|
|
3009
|
+
if (measureName !== valueKey && measureName in mapped) {
|
|
3010
|
+
mapped[valueKey] = mapped[measureName];
|
|
2317
3011
|
delete mapped[measureName];
|
|
2318
3012
|
}
|
|
2319
3013
|
return mapped;
|
|
2320
3014
|
});
|
|
2321
|
-
} catch {
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
//
|
|
2325
|
-
//
|
|
2326
|
-
//
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
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
|
+
}
|
|
2330
3025
|
|
|
2331
|
-
|
|
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
|
+
}
|
|
3033
|
+
|
|
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);
|
|
2332
3039
|
}
|
|
2333
3040
|
}
|
|
2334
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
|
+
|
|
2335
3078
|
/**
|
|
2336
3079
|
* Run a semantic-layer `dataset` (ADR-0021) and return chart-ready rows.
|
|
2337
3080
|
*
|
|
@@ -2411,6 +3154,16 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2411
3154
|
const errBody = await res.json();
|
|
2412
3155
|
detail = errBody?.message || errBody?.error || JSON.stringify(errBody);
|
|
2413
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
|
+
}
|
|
2414
3167
|
throw new Error(`Dataset query failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`);
|
|
2415
3168
|
}
|
|
2416
3169
|
|
|
@@ -2438,8 +3191,19 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2438
3191
|
}
|
|
2439
3192
|
|
|
2440
3193
|
/** Client-side aggregation fallback */
|
|
2441
|
-
|
|
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[] {
|
|
2442
3205
|
const { field, function: aggFn, groupBy } = params;
|
|
3206
|
+
const valueKey = this.aggregateValueKey(params);
|
|
2443
3207
|
const groups: Record<string, any[]> = {};
|
|
2444
3208
|
|
|
2445
3209
|
for (const record of records) {
|
|
@@ -2449,7 +3213,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2449
3213
|
}
|
|
2450
3214
|
|
|
2451
3215
|
return Object.entries(groups).map(([key, group]) => {
|
|
2452
|
-
const values = group.map(r => Number(r[field]) || 0);
|
|
3216
|
+
const values = field ? group.map(r => Number(r[field]) || 0) : [];
|
|
2453
3217
|
let result: number;
|
|
2454
3218
|
|
|
2455
3219
|
switch (aggFn) {
|
|
@@ -2460,7 +3224,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2460
3224
|
case 'sum': default: result = values.reduce((a, b) => a + b, 0); break;
|
|
2461
3225
|
}
|
|
2462
3226
|
|
|
2463
|
-
return { [groupBy]: key, [
|
|
3227
|
+
return { [groupBy]: key, [valueKey]: result };
|
|
2464
3228
|
});
|
|
2465
3229
|
}
|
|
2466
3230
|
|