@object-ui/data-objectstack 17.5.0 → 17.6.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 +219 -0
- package/dist/index.cjs +262 -357
- package/dist/index.d.cts +385 -448
- package/dist/index.d.ts +385 -448
- package/dist/index.js +258 -350
- package/package.json +5 -6
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/aggregate-capability.test.ts +0 -177
- package/src/apiAccessDenied.test.ts +0 -233
- package/src/appAccessProbe.test.ts +0 -147
- package/src/bulkDelete.test.ts +0 -75
- package/src/bulkUpdate.test.ts +0 -93
- package/src/cache/MetadataCache.test.ts +0 -477
- package/src/cache/MetadataCache.ts +0 -289
- package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
- package/src/connection.test.ts +0 -188
- package/src/contracts.ts +0 -115
- package/src/deleteView.homes.test.ts +0 -389
- package/src/deleteViewContract.types.test.ts +0 -246
- package/src/errors.test.ts +0 -426
- package/src/errors.ts +0 -293
- package/src/exportDownload.test.ts +0 -141
- package/src/filter-entry-translation.test.ts +0 -360
- package/src/filter-operator-ast-parity.test.ts +0 -135
- package/src/getObjectSchema.test.ts +0 -145
- package/src/httpFailureLogging.test.ts +0 -109
- package/src/importJob.test.ts +0 -71
- package/src/index.ts +0 -4442
- package/src/integration.ts +0 -200
- package/src/listViewOverrides.test.ts +0 -241
- package/src/listViews.test.ts +0 -175
- package/src/metadata-client.get-envelope.test.ts +0 -171
- package/src/metadata-client.saveAdvisories.test.ts +0 -226
- package/src/metadata-client.test.ts +0 -303
- package/src/metadata-client.ts +0 -1046
- package/src/occ.test.ts +0 -101
- package/src/onMutation.test.ts +0 -589
- package/src/onSaveAdvisory.test.ts +0 -311
- package/src/onWriteWarning.test.ts +0 -273
- package/src/orderby-serialization.test.ts +0 -115
- package/src/queryDataset.test.ts +0 -543
- package/src/searchAll.test.ts +0 -120
- package/src/security.ts +0 -243
- package/src/spec-symbol-batch6.test.ts +0 -326
- package/src/studio.ts +0 -152
- package/src/updateView.draft.test.ts +0 -242
- package/src/userState.test.ts +0 -315
- package/src/userState.ts +0 -238
- package/src/v3-compat.test.ts +0 -229
- package/src/validation-error.test.ts +0 -94
- package/src/viewCacheInvalidation.pin.test.ts +0 -260
- package/src/viewInvalidationSeam.pin.test.ts +0 -162
package/src/index.ts
DELETED
|
@@ -1,4442 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client';
|
|
10
|
-
import type { DroppedFieldsEvent } from '@objectstack/spec/data';
|
|
11
|
-
// #4237 — the metadata save door's advisory reader, shared with `MetadataClient`
|
|
12
|
-
// rather than forked. ONE reader, two call sites: the other client class calls it
|
|
13
|
-
// from `MetadataClient.save` (#4133/#4236), this one from the interceptor below.
|
|
14
|
-
import {
|
|
15
|
-
readSaveAdvisories,
|
|
16
|
-
type MetadataSaveAdvisoryEvent,
|
|
17
|
-
type MetadataSaveAdvisoryListener,
|
|
18
|
-
} from './metadata-client';
|
|
19
|
-
import type { AnalyticsResult, DatasetSelection } from '@objectstack/spec/contracts';
|
|
20
|
-
import type {
|
|
21
|
-
DataSource,
|
|
22
|
-
// #4564 — the canonical `deleteView` receipt shapes, declared beside the
|
|
23
|
-
// `DataSource` interface they belong to. Imported for the local uses below
|
|
24
|
-
// and re-exported under the same names further down, so this package's
|
|
25
|
-
// existing importers are untouched by the move.
|
|
26
|
-
DeleteViewResult,
|
|
27
|
-
ViewHomeDeleteOutcome,
|
|
28
|
-
BatchTransactionOperation,
|
|
29
|
-
DataSourceMutationEvent,
|
|
30
|
-
QueryParams,
|
|
31
|
-
QueryResult,
|
|
32
|
-
GlobalSearchResult,
|
|
33
|
-
GlobalSearchHit,
|
|
34
|
-
FileUploadResult,
|
|
35
|
-
ExportDownloadRequest,
|
|
36
|
-
ImportRequestOptions,
|
|
37
|
-
ImportRecordsResult,
|
|
38
|
-
CreateImportJobResult,
|
|
39
|
-
ImportJobProgressInfo,
|
|
40
|
-
ImportJobResultsInfo,
|
|
41
|
-
ImportJobSummaryInfo,
|
|
42
|
-
ImportJobUndoResult,
|
|
43
|
-
ListImportJobsOptions,
|
|
44
|
-
} from '@object-ui/types';
|
|
45
|
-
import { errorCodeIs, errorCodeIsAnyOf } from '@object-ui/types';
|
|
46
|
-
import {
|
|
47
|
-
convertFiltersToAST,
|
|
48
|
-
emulateBatchTransaction,
|
|
49
|
-
normalizeSchemaReferenceKeys,
|
|
50
|
-
type DatasetDrillRange,
|
|
51
|
-
} from '@object-ui/core';
|
|
52
|
-
import { MetadataCache } from './cache/MetadataCache';
|
|
53
|
-
import { MetadataClient } from './metadata-client';
|
|
54
|
-
import {
|
|
55
|
-
ObjectStackError,
|
|
56
|
-
MetadataNotFoundError,
|
|
57
|
-
BulkOperationError,
|
|
58
|
-
ConnectionError,
|
|
59
|
-
DataApiValidationError,
|
|
60
|
-
createErrorFromResponse,
|
|
61
|
-
} from './errors';
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Map human-readable filter operator names produced by SDUI view configs
|
|
65
|
-
* (e.g. `lead.view.ts`) to the canonical operator symbols expected by the
|
|
66
|
-
* ObjectStack server's filter AST. Unknown operators fall through unchanged
|
|
67
|
-
* so existing AST-style entries keep working.
|
|
68
|
-
*
|
|
69
|
-
* Every VALUE here must be a member of the spec's `VALID_AST_OPERATORS`
|
|
70
|
-
* (`@objectstack/spec/data`) — that set gates `isFilterAST()`, and a filter it
|
|
71
|
-
* rejects is not converted, not validated, and then silently DROPPED by
|
|
72
|
-
* driver-sql (objectstack#3948). Pinned by `filter-operator-ast-parity.test.ts`.
|
|
73
|
-
*
|
|
74
|
-
* Exported for that test. @internal
|
|
75
|
-
*/
|
|
76
|
-
export const FILTER_OPERATOR_ALIASES: Record<string, string> = {
|
|
77
|
-
equals: '=',
|
|
78
|
-
eq: '=',
|
|
79
|
-
'==': '=',
|
|
80
|
-
not_equals: '!=',
|
|
81
|
-
notequals: '!=',
|
|
82
|
-
ne: '!=',
|
|
83
|
-
greater_than: '>',
|
|
84
|
-
greaterthan: '>',
|
|
85
|
-
gt: '>',
|
|
86
|
-
greater_than_or_equal: '>=',
|
|
87
|
-
greater_than_or_equals: '>=',
|
|
88
|
-
greaterthanorequal: '>=',
|
|
89
|
-
gte: '>=',
|
|
90
|
-
less_than: '<',
|
|
91
|
-
lessthan: '<',
|
|
92
|
-
lt: '<',
|
|
93
|
-
less_than_or_equal: '<=',
|
|
94
|
-
less_than_or_equals: '<=',
|
|
95
|
-
lessthanorequal: '<=',
|
|
96
|
-
lte: '<=',
|
|
97
|
-
in: 'in',
|
|
98
|
-
not_in: 'nin',
|
|
99
|
-
notin: 'nin',
|
|
100
|
-
nin: 'nin',
|
|
101
|
-
contains: 'contains',
|
|
102
|
-
not_contains: 'notcontains',
|
|
103
|
-
notcontains: 'notcontains',
|
|
104
|
-
starts_with: 'startswith',
|
|
105
|
-
startswith: 'startswith',
|
|
106
|
-
ends_with: 'endswith',
|
|
107
|
-
endswith: 'endswith',
|
|
108
|
-
between: 'between',
|
|
109
|
-
is_null: 'isnull',
|
|
110
|
-
isnull: 'isnull',
|
|
111
|
-
is_not_null: 'isnotnull',
|
|
112
|
-
isnotnull: 'isnotnull',
|
|
113
|
-
// Date comparisons. `before`/`after` are CANONICAL members of the spec's
|
|
114
|
-
// `VIEW_FILTER_OPERATORS` (ui/view.zod.ts), so a stored view legitimately
|
|
115
|
-
// carries them — but they are absent from `VALID_AST_OPERATORS`
|
|
116
|
-
// (data/filter.zod.ts), which gates `isFilterAST()`. Without these two
|
|
117
|
-
// entries they reached the wire unchanged, the server's AST gate rejected
|
|
118
|
-
// the shape, and driver-sql skipped the filter ENTIRELY — an unfiltered
|
|
119
|
-
// result set with no error anywhere. objectstack#3948.
|
|
120
|
-
before: '<',
|
|
121
|
-
after: '>',
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
function normalizeFilterOperator(op: unknown): string | null {
|
|
125
|
-
if (typeof op !== 'string') return null;
|
|
126
|
-
const lower = op.toLowerCase();
|
|
127
|
-
return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* A filter entry this adapter cannot translate into an AST tuple.
|
|
132
|
-
*
|
|
133
|
-
* Thrown rather than skipped. Dropping one entry out of an `and` WIDENS the
|
|
134
|
-
* result set, and dropping the last one emits no `filter=` at all — every row,
|
|
135
|
-
* no error, from a query that asked for a subset. That is the same silent
|
|
136
|
-
* over-fetch the server-side drivers stopped doing in objectstack#3948, and
|
|
137
|
-
* skipping it here just moves it one layer up.
|
|
138
|
-
*
|
|
139
|
-
* Carries the code and status the data API uses for its own version of this
|
|
140
|
-
* refusal (objectstack#4121) so a failed list renders "this view's filter is
|
|
141
|
-
* malformed" rather than "check your connection" (#3066).
|
|
142
|
-
*/
|
|
143
|
-
export class MalformedFilterError extends Error {
|
|
144
|
-
readonly code = 'INVALID_FILTER';
|
|
145
|
-
readonly httpStatus = 400;
|
|
146
|
-
readonly entry: unknown;
|
|
147
|
-
readonly index: number;
|
|
148
|
-
constructor(entry: unknown, index: number) {
|
|
149
|
-
const shown = JSON.stringify(entry) ?? String(entry);
|
|
150
|
-
super(
|
|
151
|
-
`Filter entry ${index} is not a usable filter rule (${shown}). `
|
|
152
|
-
+ 'Expected { field, operator, value } with a non-empty field.',
|
|
153
|
-
);
|
|
154
|
-
this.name = 'MalformedFilterError';
|
|
155
|
-
this.entry = entry;
|
|
156
|
-
this.index = index;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Detect the malformed-filter refusal, whether raised here or by the server. */
|
|
161
|
-
export function isMalformedFilterError(error: unknown): boolean {
|
|
162
|
-
if (!error || typeof error !== 'object') return false;
|
|
163
|
-
const e = error as Record<string, unknown>;
|
|
164
|
-
return e.code === 'INVALID_FILTER' || e.name === 'MalformedFilterError';
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function objectFilterEntryToAST(entry: any): [string, string, any] | null {
|
|
168
|
-
if (!entry || typeof entry !== 'object') return null;
|
|
169
|
-
// `field` only. A `?? entry.name` fallback lived here from the day the
|
|
170
|
-
// function was written (4b93db4e6) and was unreachable for exactly as long:
|
|
171
|
-
// the shape sniff below has always keyed on `field`, so a `name`-keyed entry
|
|
172
|
-
// fell through to the "already AST" branch and shipped raw. The spec agrees
|
|
173
|
-
// it is not a real shape — `ViewFilterRuleSchema.field` is required, so a
|
|
174
|
-
// `name`-keyed rule cannot be saved as view metadata in the first place.
|
|
175
|
-
const field = entry.field;
|
|
176
|
-
const rawOp = entry.operator ?? entry.op ?? '=';
|
|
177
|
-
const op = normalizeFilterOperator(rawOp);
|
|
178
|
-
if (!field || !op) return null;
|
|
179
|
-
return [String(field), op, entry.value];
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Which of the two array filter shapes is this — object entries or an AST node?
|
|
184
|
-
*
|
|
185
|
-
* One definition on purpose. This test used to be written out twice (here and
|
|
186
|
-
* inline in `convertQueryParams`) and the copies had already drifted: the
|
|
187
|
-
* inline one omitted the `!== null` guard, so a `$filter` of `[null]` threw a
|
|
188
|
-
* TypeError on the plain `find()` path while the same value was handled on the
|
|
189
|
-
* `$expand` path. Same stored view, different answer, decided by whether it
|
|
190
|
-
* happened to expand a lookup.
|
|
191
|
-
*/
|
|
192
|
-
function isObjectFilterEntryForm(filter: readonly unknown[]): boolean {
|
|
193
|
-
const first = filter[0];
|
|
194
|
-
return filter.length > 0
|
|
195
|
-
&& typeof first === 'object'
|
|
196
|
-
&& first !== null
|
|
197
|
-
&& !Array.isArray(first)
|
|
198
|
-
&& (first as any).field !== undefined;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Translate `[{ field, operator, value }, ...]` into a filter AST node. Every
|
|
203
|
-
* entry must translate; see `MalformedFilterError` for why one that doesn't is
|
|
204
|
-
* an error rather than an omission.
|
|
205
|
-
*/
|
|
206
|
-
function objectFilterEntriesToAST(entries: readonly unknown[]): unknown[] {
|
|
207
|
-
const nodes = entries.map((entry, i) => {
|
|
208
|
-
// An entry that is itself an array is already a node — a mixed array keeps
|
|
209
|
-
// both conditions instead of losing one to a drop or the whole query to an
|
|
210
|
-
// error.
|
|
211
|
-
if (Array.isArray(entry)) return translateFilterChild(entry);
|
|
212
|
-
const tuple = objectFilterEntryToAST(entry);
|
|
213
|
-
if (!tuple) throw new MalformedFilterError(entry, i);
|
|
214
|
-
return tuple;
|
|
215
|
-
});
|
|
216
|
-
return nodes.length === 1 ? (nodes[0] as unknown[]) : ['and', ...nodes];
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** Logical heads `parseFilterAST` recognizes (`data/filter.zod.ts`). No `not`. */
|
|
220
|
-
const LOGICAL_AST_HEADS = new Set(['and', 'or']);
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Translate a filter array at EVERY level, not just the top one.
|
|
224
|
-
*
|
|
225
|
-
* Translating only the top level left the commonest composite filter there is
|
|
226
|
-
* shipping raw. A list whose view carries a stored filter and whose user adds
|
|
227
|
-
* one in the panel produces `['and', <ViewFilterRule[]>, <AST tuples>]`; the
|
|
228
|
-
* head is the string `and`, so the old top-level-only check called the whole
|
|
229
|
-
* thing "already AST" and sent the rules on untouched.
|
|
230
|
-
*
|
|
231
|
-
* What the server does with that depends on its version, and both answers are
|
|
232
|
-
* wrong:
|
|
233
|
-
*
|
|
234
|
-
* ```
|
|
235
|
-
* const n = ['and', [{ field: 'stage', operator: 'eq', value: 'won' }], [['amount', '>', 1]]];
|
|
236
|
-
* isFilterAST(n) // false — a bare rule object is not an AST child
|
|
237
|
-
* parseFilterAST(n) // { amount: { $gt: 1 } } ← `stage = won` is simply GONE
|
|
238
|
-
* ```
|
|
239
|
-
*
|
|
240
|
-
* Since objectstack#4121 the `isFilterAST` gate turns that into a 400 and the
|
|
241
|
-
* list fails to load. Before it — or anywhere `parseFilterAST` is reached
|
|
242
|
-
* without the gate — the view's own condition is dropped without a word and the
|
|
243
|
-
* list shows records the view exists to exclude.
|
|
244
|
-
*/
|
|
245
|
-
function translateFilterArray(filter: unknown[]): unknown[] {
|
|
246
|
-
if (isObjectFilterEntryForm(filter)) return objectFilterEntriesToAST(filter);
|
|
247
|
-
const head = filter[0];
|
|
248
|
-
if (typeof head === 'string' && LOGICAL_AST_HEADS.has(head.toLowerCase())) {
|
|
249
|
-
return [head, ...filter.slice(1).map(translateFilterChild)];
|
|
250
|
-
}
|
|
251
|
-
// Legacy flat array of child nodes: [[...], [...]] — implicit AND.
|
|
252
|
-
if (filter.every((child) => Array.isArray(child))) return filter.map(translateFilterChild);
|
|
253
|
-
// A comparison tuple, or a shape we do not recognize. Leave it alone; the
|
|
254
|
-
// server decides, and since objectstack#4121 it says so with a 400.
|
|
255
|
-
return filter;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* A child of a logical node: another array node, a bare rule object, or a value
|
|
260
|
-
* we leave alone.
|
|
261
|
-
*
|
|
262
|
-
* The bare-rule case comes from producers that SPREAD a `ViewFilterRule[]` into
|
|
263
|
-
* an `and` (`['and', ...rules, ...tuples]`) instead of wrapping it. That puts
|
|
264
|
-
* rule objects where the AST expects nodes, and the server has no good answer:
|
|
265
|
-
* `isFilterAST` rejects it (a 400 since objectstack#4121), while
|
|
266
|
-
* `parseFilterAST` reads the rule as a MongoDB condition and filters on columns
|
|
267
|
-
* literally named `field` / `operator` / `value` — three columns that do not
|
|
268
|
-
* exist, so the honest-looking result is empty.
|
|
269
|
-
*
|
|
270
|
-
* Only rule-SHAPED objects are translated: a child with no `field` is a genuine
|
|
271
|
-
* MongoDB condition (`{ status: 'active' }`) and must pass through untouched.
|
|
272
|
-
* Same discriminator `isObjectFilterEntryForm` uses at the top level.
|
|
273
|
-
*/
|
|
274
|
-
function translateFilterChild(child: unknown): unknown {
|
|
275
|
-
if (Array.isArray(child)) return child.length > 0 ? translateFilterArray(child) : child;
|
|
276
|
-
if (child && typeof child === 'object' && (child as any).field !== undefined) {
|
|
277
|
-
const tuple = objectFilterEntryToAST(child);
|
|
278
|
-
if (tuple) return tuple;
|
|
279
|
-
}
|
|
280
|
-
return child;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Translate any of the filter shapes accepted by ObjectUI into the AST format
|
|
285
|
-
* understood by the ObjectStack server's `parseFilterAST()`.
|
|
286
|
-
*
|
|
287
|
-
* Accepted inputs:
|
|
288
|
-
* - `[{ field, operator, value }, ...]` — ViewFilterRule[] from view configs
|
|
289
|
-
* - `[field, op, value]` — single AST tuple (passed through)
|
|
290
|
-
* - `['and'|'or', ...children]` — logical AST node (passed through)
|
|
291
|
-
* - `[[...], [...]]` — legacy nested AST (passed through)
|
|
292
|
-
* - `{ field: value }` / `{ field: { $op: value } }` — MongoDB-style object
|
|
293
|
-
*
|
|
294
|
-
* Returns `undefined` when the input is empty/unrecognized so callers can
|
|
295
|
-
* skip emitting `?filter=` entirely.
|
|
296
|
-
*/
|
|
297
|
-
function translateFilterToAST(filter: unknown): unknown | undefined {
|
|
298
|
-
if (filter === undefined || filter === null) return undefined;
|
|
299
|
-
|
|
300
|
-
if (Array.isArray(filter)) {
|
|
301
|
-
if (filter.length === 0) return undefined;
|
|
302
|
-
return translateFilterArray(filter);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
if (typeof filter === 'object') {
|
|
306
|
-
if (Object.keys(filter as Record<string, unknown>).length === 0) return undefined;
|
|
307
|
-
// Same conversion `convertQueryParams` applies. This branch used to return
|
|
308
|
-
// the object VERBATIM, so the two `find()` routes disagreed about the same
|
|
309
|
-
// filter — decided, as ever, by whether the query happened to expand a
|
|
310
|
-
// lookup. Measured across 21 operator shapes, four diverged; the one that
|
|
311
|
-
// mattered is that `convertFiltersToAST`'s unknown-operator guard — added
|
|
312
|
-
// expressly "to avoid silent failure" — never ran on this route, so a typo'd
|
|
313
|
-
// operator threw on a plain read and shipped silently on an expanded one.
|
|
314
|
-
return convertFiltersToAST(filter as Record<string, unknown>);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
return undefined;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Serialize a `$orderby` to the server's `sort` shorthand
|
|
322
|
-
* (`field,-other_field`), for every shape `QueryParams['$orderby']` declares.
|
|
323
|
-
*
|
|
324
|
-
* The type declares four — `string`, `string[]`, `SortNode[]`,
|
|
325
|
-
* `Record<field, direction>` — and the two `find()` routes each open-coded a
|
|
326
|
-
* fold that handled three of them. The missing one was the bare string, and it
|
|
327
|
-
* did not degrade quietly: `Object.entries('name asc')` enumerates a string's
|
|
328
|
-
* character indices, so the request went out as `sort=0,1,2,3,4,5,6,7`. Against
|
|
329
|
-
* a server that rejects an unreadable sort rather than ignoring it
|
|
330
|
-
* (objectstack#4226), that is a `400 INVALID_SORT` and an empty list — so a
|
|
331
|
-
* standalone `ObjectGrid` with a `sort` in its metadata, which is exactly the
|
|
332
|
-
* shape it builds (`ObjectGrid.tsx`: `` `${field} ${order}` ``), failed to load
|
|
333
|
-
* at all.
|
|
334
|
-
*
|
|
335
|
-
* One serializer for both routes, for the reason the filter path already has
|
|
336
|
-
* one: two copies of a fold can only agree by inspection, and these two did not.
|
|
337
|
-
*
|
|
338
|
-
* Returns `undefined` when nothing is sortable, so callers skip the parameter
|
|
339
|
-
* entirely rather than sending an empty one.
|
|
340
|
-
*/
|
|
341
|
-
export function serializeOrderBy(orderby: QueryParams['$orderby']): string | undefined {
|
|
342
|
-
if (orderby === undefined || orderby === null) return undefined;
|
|
343
|
-
|
|
344
|
-
// `field asc` / `-field` / a comma-separated list of either — already the
|
|
345
|
-
// wire shorthand the server parses, so it rides through untouched.
|
|
346
|
-
if (typeof orderby === 'string') {
|
|
347
|
-
const trimmed = orderby.trim();
|
|
348
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
const shorthand = (field: string, order?: unknown) =>
|
|
352
|
-
String(order).toLowerCase() === 'desc' ? `-${field}` : field;
|
|
353
|
-
|
|
354
|
-
if (Array.isArray(orderby)) {
|
|
355
|
-
const parts = orderby
|
|
356
|
-
.map((item) => (typeof item === 'string' ? item.trim() : shorthand(item.field, item.order)))
|
|
357
|
-
.filter((s) => s.length > 0);
|
|
358
|
-
return parts.length > 0 ? parts.join(',') : undefined;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (typeof orderby === 'object') {
|
|
362
|
-
const parts = Object.entries(orderby).map(([field, order]) => shorthand(field, order));
|
|
363
|
-
return parts.length > 0 ? parts.join(',') : undefined;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
return undefined;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Module-level discovery cache. Multiple ObjectStackAdapter instances pointed
|
|
370
|
-
// at the same baseUrl (e.g. ConditionalAuthWrapper's throwaway adapter +
|
|
371
|
-
// AdapterProvider's main adapter) would otherwise each fire `/discovery`. By
|
|
372
|
-
// keying on baseUrl we collapse them to a single network round trip per origin.
|
|
373
|
-
const discoveryCache = new Map<string, Promise<unknown>>();
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* Fetch the server `discovery` document once per (baseUrl) and reuse the
|
|
377
|
-
* resulting Promise. Used by `ObjectStackAdapter.connect()` (and any caller
|
|
378
|
-
* that wants the discovery payload without spinning up a new client).
|
|
379
|
-
*/
|
|
380
|
-
export async function getSharedDiscovery(
|
|
381
|
-
baseUrl: string,
|
|
382
|
-
fetcher: () => Promise<unknown>,
|
|
383
|
-
): Promise<unknown> {
|
|
384
|
-
const key = baseUrl || '<default>';
|
|
385
|
-
const cached = discoveryCache.get(key);
|
|
386
|
-
if (cached) return cached;
|
|
387
|
-
const p = fetcher().catch((err) => {
|
|
388
|
-
// Allow retry on failure
|
|
389
|
-
discoveryCache.delete(key);
|
|
390
|
-
throw err;
|
|
391
|
-
});
|
|
392
|
-
discoveryCache.set(key, p);
|
|
393
|
-
return p;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/** Test/dev helper to drop the cache (e.g. on logout or origin change). */
|
|
397
|
-
export function clearSharedDiscoveryCache(): void {
|
|
398
|
-
discoveryCache.clear();
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Read the cross-object atomic-batch capability from a `discovery` document
|
|
403
|
-
* (framework #3298 / objectui #2693). The server advertises it hierarchically
|
|
404
|
-
* under `capabilities.transactionalBatch.enabled`; the published
|
|
405
|
-
* `@objectstack/client` also accepts the flat `capabilities.transactionalBatch:
|
|
406
|
-
* boolean` form and normalizes the two — mirror that here so the adapter reads
|
|
407
|
-
* the same bit regardless of which shape reaches it.
|
|
408
|
-
*
|
|
409
|
-
* Returns:
|
|
410
|
-
* - `true` — the backend GUARANTEES an atomic `/batch` (declared === enforced,
|
|
411
|
-
* i.e. the route is mounted AND the runtime can honour a transaction): the
|
|
412
|
-
* client may drop its non-atomic fallback and treat any batch failure as a
|
|
413
|
-
* real error.
|
|
414
|
-
* - `false` — the backend explicitly does NOT (route absent, or a runtime that
|
|
415
|
-
* can't open a transaction).
|
|
416
|
-
* - `undefined` — the capability is absent, i.e. the backend predates #3298;
|
|
417
|
-
* the caller must keep the legacy runtime-probe fallback (we can't tell
|
|
418
|
-
* whether `/batch` exists without trying it).
|
|
419
|
-
*/
|
|
420
|
-
export function readTransactionalBatchCapability(
|
|
421
|
-
discovery: unknown,
|
|
422
|
-
): boolean | undefined {
|
|
423
|
-
const caps = (discovery as { capabilities?: unknown } | null | undefined)?.capabilities;
|
|
424
|
-
if (!caps || typeof caps !== 'object') return undefined;
|
|
425
|
-
const value = (caps as Record<string, unknown>).transactionalBatch;
|
|
426
|
-
// Flat form: `{ transactionalBatch: true }`.
|
|
427
|
-
if (typeof value === 'boolean') return value;
|
|
428
|
-
// Hierarchical form: `{ transactionalBatch: { enabled: true } }`.
|
|
429
|
-
if (
|
|
430
|
-
value &&
|
|
431
|
-
typeof value === 'object' &&
|
|
432
|
-
typeof (value as { enabled?: unknown }).enabled === 'boolean'
|
|
433
|
-
) {
|
|
434
|
-
return (value as { enabled: boolean }).enabled;
|
|
435
|
-
}
|
|
436
|
-
return undefined;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* Detect "missing resource" errors regardless of where they originate.
|
|
441
|
-
*
|
|
442
|
-
* The ObjectStack client decorates thrown errors with `httpStatus` (and a
|
|
443
|
-
* machine-readable `code` such as `object_not_found`/`record_not_found`),
|
|
444
|
-
* while raw `fetch()` callers may surface `status` or `statusCode`. Treat
|
|
445
|
-
* any of these as a 404 so callers can degrade gracefully instead of
|
|
446
|
-
* tripping on the property-name mismatch.
|
|
447
|
-
*/
|
|
448
|
-
export function is404Error(error: unknown): boolean {
|
|
449
|
-
if (!error || typeof error !== 'object') return false;
|
|
450
|
-
const err = error as Record<string, unknown>;
|
|
451
|
-
if (err.httpStatus === 404 || err.status === 404 || err.statusCode === 404) {
|
|
452
|
-
return true;
|
|
453
|
-
}
|
|
454
|
-
const code = typeof err.code === 'string' ? err.code : '';
|
|
455
|
-
return errorCodeIsAnyOf({ code }, ['OBJECT_NOT_FOUND', 'RECORD_NOT_FOUND']);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* The two denials the server derives from an object's `enable` block —
|
|
460
|
-
* `apiAccessDenialFromEnable` (objectstack `packages/rest/src/rest-server.ts`).
|
|
461
|
-
*
|
|
462
|
-
* - `OBJECT_API_DISABLED` (404) — `enable.apiEnabled: false`; the object is
|
|
463
|
-
* not exposed over the data API at all.
|
|
464
|
-
* - `OBJECT_API_METHOD_NOT_ALLOWED` (405) — the operation is absent from the
|
|
465
|
-
* `enable.apiMethods` whitelist.
|
|
466
|
-
*
|
|
467
|
-
* Both are **pure functions of the object's metadata**: no user, no permission,
|
|
468
|
-
* no context, no request body. So neither is transient and neither is
|
|
469
|
-
* per-user — when one happens it is a permanent property of that object, and
|
|
470
|
-
* every retry of every persona gets the identical answer.
|
|
471
|
-
*
|
|
472
|
-
* That is exactly why they must not be degraded into "no data". A 404 from a
|
|
473
|
-
* missing collection means *this backend doesn't have that table*, which the
|
|
474
|
-
* optional-collection probes below legitimately read as "feature unavailable";
|
|
475
|
-
* a 404 from `OBJECT_API_DISABLED` means *this page can never work*. Answering
|
|
476
|
-
* the second with an empty result set renders "you have no records" over a
|
|
477
|
-
* surface that is not allowed to have any (objectui#4408 — it also hid the
|
|
478
|
-
* upstream defect objectstack#7544 for its entire life, because a merely
|
|
479
|
-
* unpopulated page invites nobody to click through).
|
|
480
|
-
*/
|
|
481
|
-
export const API_ACCESS_DENIED_CODES = [
|
|
482
|
-
'OBJECT_API_DISABLED',
|
|
483
|
-
'OBJECT_API_METHOD_NOT_ALLOWED',
|
|
484
|
-
] as const;
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* True when `error` is an `enable`-block API denial (see
|
|
488
|
-
* {@link API_ACCESS_DENIED_CODES}).
|
|
489
|
-
*
|
|
490
|
-
* Discriminates on the ADR-0112 `code`, never on the status: 404 alone cannot
|
|
491
|
-
* separate a disabled object from a missing collection, and 405 alone cannot
|
|
492
|
-
* separate a withheld method from any other method rejection. The code survives
|
|
493
|
-
* the transport — `@objectstack/client`'s fetch wrapper stamps `error.code`
|
|
494
|
-
* from the response envelope, and both spellings are declared members of the
|
|
495
|
-
* spec's `StandardErrorCode` — so no heuristic on status is needed or wanted.
|
|
496
|
-
*/
|
|
497
|
-
export function isApiAccessDeniedError(error: unknown): boolean {
|
|
498
|
-
return errorCodeIsAnyOf(error, API_ACCESS_DENIED_CODES);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/**
|
|
502
|
-
* What the by-name meta app route said about THIS session's access to an app
|
|
503
|
-
* (objectui#4252 / objectstack#8013).
|
|
504
|
-
*
|
|
505
|
-
* - `granted` — the route served the app document.
|
|
506
|
-
* - `denied` — the app EXISTS and the session lacks its `requiredPermissions`.
|
|
507
|
-
* The only verdict a caller may render as an authorization refusal.
|
|
508
|
-
* - `unknown` — anything else: an absent app, an unpublished one, an app
|
|
509
|
-
* withheld by an absent optional service, an unreachable server, an adapter
|
|
510
|
-
* that cannot ask. All of these are cases where the server declined to say
|
|
511
|
-
* that a permission of the caller's is missing, so no caller may claim it.
|
|
512
|
-
*
|
|
513
|
-
* Three values rather than a boolean because the third is not a shade of the
|
|
514
|
-
* other two: "the app is missing" and "I could not find out" both have to leave
|
|
515
|
-
* the caller's existing copy alone, and collapsing them into `false` invites a
|
|
516
|
-
* consumer to read a failed probe as a positive absence.
|
|
517
|
-
*/
|
|
518
|
-
export type AppAccessVerdict = 'granted' | 'denied' | 'unknown';
|
|
519
|
-
|
|
520
|
-
/**
|
|
521
|
-
* The ADR-0112 standard catalog code the by-name meta app route answers with
|
|
522
|
-
* when an app exists and the session lacks its `requiredPermissions`
|
|
523
|
-
* (objectstack#8013, `sendError(res, 403, 'PERMISSION_DENIED', …)` in
|
|
524
|
-
* `packages/rest/src/rest-server.ts`).
|
|
525
|
-
*
|
|
526
|
-
* Deliberately NOT a member of {@link API_ACCESS_DENIED_CODES}: those two are
|
|
527
|
-
* pure functions of an object's `enable` metadata — permanent, identical for
|
|
528
|
-
* every persona — whereas this one is a statement about the CALLER, and the same
|
|
529
|
-
* request by a different session succeeds. Same word "denied", different
|
|
530
|
-
* question, so a consumer that wants one must never match the other.
|
|
531
|
-
*/
|
|
532
|
-
export const APP_PERMISSION_DENIED_CODE = 'PERMISSION_DENIED';
|
|
533
|
-
|
|
534
|
-
/**
|
|
535
|
-
* True when `error` is the by-name app route's permission denial.
|
|
536
|
-
*
|
|
537
|
-
* Discriminates on the ADR-0112 `code`, never on the status (objectui#4408): the
|
|
538
|
-
* route answers 403 for this and 404 for absence today, but a status is a
|
|
539
|
-
* transport fact many conditions share, while the code is the contract. The
|
|
540
|
-
* console's whole reason to call this is to tell two REFUSALS apart, and both
|
|
541
|
-
* are errors.
|
|
542
|
-
*/
|
|
543
|
-
export function isAppPermissionDeniedError(error: unknown): boolean {
|
|
544
|
-
return errorCodeIs(error, APP_PERMISSION_DENIED_CODE);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
/**
|
|
548
|
-
* Thrown when the deployment has no analytics capability installed
|
|
549
|
-
* (framework#3891 / #4019).
|
|
550
|
-
*
|
|
551
|
-
* The framework retired its degraded in-kernel analytics fallback — it dropped
|
|
552
|
-
* the caller's RLS/tenant scope and ignored the contract filter, so it answered
|
|
553
|
-
* 200 with over-broad numbers. `@objectstack/service-analytics` is now the
|
|
554
|
-
* domain's only implementation, and a deployment without it answers:
|
|
555
|
-
*
|
|
556
|
-
* - `POST /api/v1/analytics/query` → **404** (the routes aren't even mounted);
|
|
557
|
-
* - `POST /api/v1/analytics/dataset/query` → **501 NOT_IMPLEMENTED**.
|
|
558
|
-
*
|
|
559
|
-
* Neither is a bug to report as a stack trace: it is a deployment that hasn't
|
|
560
|
-
* installed the capability. This error carries a message a UI can show as-is.
|
|
561
|
-
*/
|
|
562
|
-
export class AnalyticsNotInstalledError extends Error {
|
|
563
|
-
readonly code = 'ANALYTICS_NOT_INSTALLED';
|
|
564
|
-
/** The surface that was unavailable, for the message a host renders. */
|
|
565
|
-
readonly surface: string;
|
|
566
|
-
constructor(surface: string, detail?: string) {
|
|
567
|
-
super(
|
|
568
|
-
`Analytics capability is not installed on this deployment — ${surface} is unavailable. ` +
|
|
569
|
-
'Install @objectstack/service-analytics and mount AnalyticsServicePlugin to enable it.' +
|
|
570
|
-
(detail ? ` (server said: ${detail})` : ''),
|
|
571
|
-
);
|
|
572
|
-
this.name = 'AnalyticsNotInstalledError';
|
|
573
|
-
this.surface = surface;
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
/** True when `error` is an {@link AnalyticsNotInstalledError} (or its wire twin). */
|
|
578
|
-
export function isAnalyticsNotInstalledError(error: unknown): boolean {
|
|
579
|
-
if (!error || typeof error !== 'object') return false;
|
|
580
|
-
return (error as { code?: unknown }).code === 'ANALYTICS_NOT_INSTALLED';
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/**
|
|
584
|
-
* Thrown when the server REJECTED the analytics query body (HTTP 400 —
|
|
585
|
-
* `VALIDATION_FAILED` since framework#4010 validates `/analytics/query` at the
|
|
586
|
-
* entry against the canonical bare `AnalyticsQuery` shape).
|
|
587
|
-
*
|
|
588
|
-
* Distinct from {@link AnalyticsNotInstalledError} on purpose: this one is a
|
|
589
|
-
* defect in what WE sent, so it must never be answered with the client-side
|
|
590
|
-
* fallback. Numbers produced by a different code path would look plausible and
|
|
591
|
-
* bury the contract violation — the misdirection framework#3878 documented.
|
|
592
|
-
*/
|
|
593
|
-
export class AnalyticsQueryRejectedError extends Error {
|
|
594
|
-
readonly code = 'ANALYTICS_QUERY_REJECTED';
|
|
595
|
-
/** The server's own error code (e.g. `VALIDATION_FAILED`), when it sent one. */
|
|
596
|
-
readonly serverCode?: string;
|
|
597
|
-
constructor(detail?: string, serverCode?: string) {
|
|
598
|
-
super(
|
|
599
|
-
`Analytics rejected the query: ${detail ?? 'the request body does not match the AnalyticsQuery contract'}`,
|
|
600
|
-
);
|
|
601
|
-
this.name = 'AnalyticsQueryRejectedError';
|
|
602
|
-
this.serverCode = serverCode;
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
/**
|
|
607
|
-
* Classify a FAILED analytics call so the caller knows whether to degrade or
|
|
608
|
-
* to surface the failure.
|
|
609
|
-
*
|
|
610
|
-
* `@objectstack/client`'s fetch wrapper throws on a non-2xx, decorating the
|
|
611
|
-
* error with the semantic `code` string and the numeric `httpStatus` (the
|
|
612
|
-
* ADR-0112 / framework#3842 shape this repo already reads elsewhere). Two
|
|
613
|
-
* outcomes must NOT be conflated:
|
|
614
|
-
*
|
|
615
|
-
* - **`not-installed`** — the deployment has no analytics service. Since
|
|
616
|
-
* framework#3891 retired the degraded in-kernel shim, that is a 404 (the
|
|
617
|
-
* routes aren't mounted at all, framework#4019) or a 501 `NOT_IMPLEMENTED`
|
|
618
|
-
* (REST's dataset route with no service behind it). Degrading to a
|
|
619
|
-
* client-side aggregate over a scoped `find()` is CORRECT here.
|
|
620
|
-
* - **`rejected`** — the server refused OUR body (400 `VALIDATION_FAILED`;
|
|
621
|
-
* framework#4010 validates `/analytics/query` at the entry). Degrading
|
|
622
|
-
* would answer our own contract violation with plausible numbers from a
|
|
623
|
-
* different code path and bury it — the misdirection framework#3878
|
|
624
|
-
* documented. It must be surfaced.
|
|
625
|
-
*
|
|
626
|
-
* Anything else (5xx, network) is `unknown`: degrade, but silently — it is a
|
|
627
|
-
* transient failure, not a deployment that is missing a capability.
|
|
628
|
-
*/
|
|
629
|
-
export function classifyAnalyticsFailure(
|
|
630
|
-
error: unknown,
|
|
631
|
-
): { kind: 'not-installed' | 'rejected' | 'unknown'; code?: string; message?: string } {
|
|
632
|
-
const err = (error ?? {}) as Record<string, unknown>;
|
|
633
|
-
const code = typeof err.code === 'string' ? err.code : undefined;
|
|
634
|
-
const message = typeof err.message === 'string' ? err.message : undefined;
|
|
635
|
-
const status =
|
|
636
|
-
typeof err.httpStatus === 'number' ? err.httpStatus
|
|
637
|
-
: typeof err.status === 'number' ? err.status
|
|
638
|
-
: typeof err.statusCode === 'number' ? err.statusCode
|
|
639
|
-
: undefined;
|
|
640
|
-
|
|
641
|
-
if (code === 'VALIDATION_FAILED' || status === 400) return { kind: 'rejected', code, message };
|
|
642
|
-
if (status === 404 || status === 501 || code === 'NOT_IMPLEMENTED' || code === 'ROUTE_NOT_FOUND') {
|
|
643
|
-
return { kind: 'not-installed', code, message };
|
|
644
|
-
}
|
|
645
|
-
return { kind: 'unknown', code, message };
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/**
|
|
649
|
-
* Thrown by `update()` / `delete()` when the server returns
|
|
650
|
-
* `409 CONCURRENT_UPDATE` — i.e. the record was modified by someone else
|
|
651
|
-
* between when the caller last read it and when they attempted to write.
|
|
652
|
-
*
|
|
653
|
-
* The error carries the current server-side `updated_at` version and the
|
|
654
|
-
* full latest record so the UI can render an informed conflict-resolution
|
|
655
|
-
* dialog (typically "Reload latest" / "Overwrite anyway" / "Cancel").
|
|
656
|
-
*
|
|
657
|
-
* Mirrors the {@link ConcurrentUpdateError} thrown by
|
|
658
|
-
* `@objectstack/objectql`'s protocol; the wire shape is:
|
|
659
|
-
* ```json
|
|
660
|
-
* { "code": "CONCURRENT_UPDATE",
|
|
661
|
-
* "error": "<message>",
|
|
662
|
-
* "currentVersion": "<updated_at>",
|
|
663
|
-
* "currentRecord": { ...latest... } }
|
|
664
|
-
* ```
|
|
665
|
-
*/
|
|
666
|
-
export class ConcurrentUpdateError extends Error {
|
|
667
|
-
readonly code = 'CONCURRENT_UPDATE';
|
|
668
|
-
readonly httpStatus = 409;
|
|
669
|
-
readonly currentVersion: string | null;
|
|
670
|
-
readonly currentRecord: unknown;
|
|
671
|
-
constructor(opts: { currentVersion: string | null; currentRecord: unknown; message?: string }) {
|
|
672
|
-
super(opts.message ?? 'Record was modified by another user');
|
|
673
|
-
this.name = 'ConcurrentUpdateError';
|
|
674
|
-
this.currentVersion = opts.currentVersion;
|
|
675
|
-
this.currentRecord = opts.currentRecord;
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
/**
|
|
680
|
-
* Detect "concurrent update" errors raised by the platform. The wire
|
|
681
|
-
* shape is `409` + `code: 'CONCURRENT_UPDATE'`. The client surfaces
|
|
682
|
-
* extra details on `error.details` (full response body).
|
|
683
|
-
*/
|
|
684
|
-
export function isConcurrentUpdateError(error: unknown): error is ConcurrentUpdateError {
|
|
685
|
-
if (!error || typeof error !== 'object') return false;
|
|
686
|
-
const e = error as Record<string, unknown>;
|
|
687
|
-
return e.code === 'CONCURRENT_UPDATE' || e.name === 'ConcurrentUpdateError';
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
/**
|
|
691
|
-
* Convert any error thrown by the upstream client into a typed error when we
|
|
692
|
-
* recognise its shape. Returns the original error untouched otherwise, so
|
|
693
|
-
* callers can simply `throw normaliseClientError(err)` from their catch blocks.
|
|
694
|
-
*
|
|
695
|
-
* Two shapes are recognised:
|
|
696
|
-
* - `409` + `CONCURRENT_UPDATE` → {@link ConcurrentUpdateError};
|
|
697
|
-
* - `400` + `VALIDATION_FAILED` → {@link DataApiValidationError}, carrying the
|
|
698
|
-
* server's per-field entries so a form can mark the offending inputs
|
|
699
|
-
* instead of showing one undirected toast.
|
|
700
|
-
*/
|
|
701
|
-
export function normaliseClientError(error: unknown): unknown {
|
|
702
|
-
if (!error || typeof error !== 'object') return error;
|
|
703
|
-
const e = error as Record<string, unknown>;
|
|
704
|
-
// The client sets `details` to the parsed body's `details`, falling back to
|
|
705
|
-
// the WHOLE body — and the validation envelope has no `details` key, so this
|
|
706
|
-
// is where `fields[]` lands.
|
|
707
|
-
const details = (e.details ?? {}) as Record<string, unknown>;
|
|
708
|
-
|
|
709
|
-
if (e.code === 'VALIDATION_FAILED' || e.name === 'ValidationError') {
|
|
710
|
-
const rawFields = Array.isArray(details.fields)
|
|
711
|
-
? details.fields
|
|
712
|
-
: Array.isArray((e as { fields?: unknown }).fields)
|
|
713
|
-
? ((e as { fields: unknown[] }).fields)
|
|
714
|
-
: [];
|
|
715
|
-
const validationErrors = rawFields
|
|
716
|
-
.map((f) => {
|
|
717
|
-
const rec = (f ?? {}) as Record<string, unknown>;
|
|
718
|
-
const field = typeof rec.field === 'string' ? rec.field : undefined;
|
|
719
|
-
if (!field) return null;
|
|
720
|
-
const message =
|
|
721
|
-
typeof rec.message === 'string' && rec.message.trim()
|
|
722
|
-
? rec.message
|
|
723
|
-
: typeof rec.code === 'string'
|
|
724
|
-
? rec.code
|
|
725
|
-
: '';
|
|
726
|
-
return { field, message };
|
|
727
|
-
})
|
|
728
|
-
.filter((x): x is { field: string; message: string } => x !== null);
|
|
729
|
-
|
|
730
|
-
return new DataApiValidationError(
|
|
731
|
-
typeof e.message === 'string' ? e.message : 'Validation failed',
|
|
732
|
-
validationErrors[0]?.field,
|
|
733
|
-
validationErrors,
|
|
734
|
-
{ fields: rawFields },
|
|
735
|
-
);
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
if (e.code !== 'CONCURRENT_UPDATE' && e.httpStatus !== 409) return error;
|
|
739
|
-
if (e.code !== 'CONCURRENT_UPDATE') return error;
|
|
740
|
-
return new ConcurrentUpdateError({
|
|
741
|
-
currentVersion: typeof details.currentVersion === 'string' ? details.currentVersion : null,
|
|
742
|
-
currentRecord: details.currentRecord ?? null,
|
|
743
|
-
message: typeof e.message === 'string' ? e.message : undefined,
|
|
744
|
-
});
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
/**
|
|
748
|
-
* Fold an @objectstack/client HTTP-failure `meta` bag into the log MESSAGE.
|
|
749
|
-
*
|
|
750
|
-
* The client already hands us everything worth knowing —
|
|
751
|
-
* `logger.error("HTTP request failed", undefined, { method, url, status, error })`
|
|
752
|
-
* — but it hands it as the THIRD argument. Everything that flattens a console
|
|
753
|
-
* record to text (a headless/CDP console capture, a log shipper, a copied
|
|
754
|
-
* DevTools line) keeps only the message and renders the rest as `[object
|
|
755
|
-
* Object]` / `Object`, so a wall of failures carried no method, no URL and no
|
|
756
|
-
* status: the reporter of objectui#4042 had to diff the network panel by hand
|
|
757
|
-
* to find out that 30 red lines were all one benign pre-login burst.
|
|
758
|
-
*
|
|
759
|
-
* So the identifying fields go into the string itself, and the structured bag
|
|
760
|
-
* is STILL passed alongside for DevTools to expand — text for the flatteners,
|
|
761
|
-
* object for the inspectors, neither at the other's expense.
|
|
762
|
-
*
|
|
763
|
-
* Exported for tests. Returns `null` when `meta` carries none of the three
|
|
764
|
-
* fields, so callers keep the original message rather than printing a husk.
|
|
765
|
-
*/
|
|
766
|
-
export function formatHttpFailureMessage(
|
|
767
|
-
message: string,
|
|
768
|
-
meta?: Record<string, any>,
|
|
769
|
-
): string | null {
|
|
770
|
-
if (!meta || typeof meta !== 'object') return null;
|
|
771
|
-
const method = typeof meta.method === 'string' && meta.method ? meta.method : undefined;
|
|
772
|
-
const url = typeof meta.url === 'string' && meta.url ? meta.url : undefined;
|
|
773
|
-
const status =
|
|
774
|
-
typeof meta.status === 'number'
|
|
775
|
-
? meta.status
|
|
776
|
-
: typeof meta.statusCode === 'number'
|
|
777
|
-
? meta.statusCode
|
|
778
|
-
: undefined;
|
|
779
|
-
if (!method && !url && status === undefined) return null;
|
|
780
|
-
|
|
781
|
-
// `code` is the ADR-0112 semantic error code. The client puts the parsed
|
|
782
|
-
// body under `meta.error`; some call sites hoist the code to the top level.
|
|
783
|
-
const errBody = meta.error && typeof meta.error === 'object' ? meta.error : undefined;
|
|
784
|
-
const rawCode =
|
|
785
|
-
(typeof meta.code === 'string' && meta.code) ||
|
|
786
|
-
(errBody && typeof (errBody as Record<string, unknown>).code === 'string'
|
|
787
|
-
? ((errBody as Record<string, string>).code)
|
|
788
|
-
: undefined);
|
|
789
|
-
|
|
790
|
-
const parts = [method ?? 'GET', url ?? '<unknown url>'];
|
|
791
|
-
parts.push(`-> ${status ?? 'no status'}`);
|
|
792
|
-
if (rawCode) parts.push(`[${rawCode}]`);
|
|
793
|
-
return `${message}: ${parts.join(' ')}`;
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
/**
|
|
797
|
-
* Build a Logger compatible with @objectstack/client that (a) spells every
|
|
798
|
-
* request failure out in the message — see {@link formatHttpFailureMessage} —
|
|
799
|
-
* and (b) demotes expected 404 noise to console.debug. The client logs every
|
|
800
|
-
* non-2xx response with
|
|
801
|
-
* `logger.error("HTTP request failed", undefined, { method, url, status, error })`,
|
|
802
|
-
* but 404s on optional collections (sys_presence, sys_activity, …) are part of
|
|
803
|
-
* normal degraded operation when those plugins aren't installed on the
|
|
804
|
-
* server — they should not surface as errors in the browser DevTools.
|
|
805
|
-
*
|
|
806
|
-
* NOTE the asymmetry, and keep it: 404-on-an-optional-collection is demoted
|
|
807
|
-
* because it is an EXPECTED outcome of a request we still mean to make. No
|
|
808
|
-
* other status is demoted — a 401 that survives the console's session gate
|
|
809
|
-
* (objectui#4042: a mid-session expiry, say) is a real event and must stay a
|
|
810
|
-
* visible, fully-identified error. The cure for doomed requests is not issuing
|
|
811
|
-
* them, never hiding them once issued.
|
|
812
|
-
*
|
|
813
|
-
* Returned object is loosely typed because the spec's Logger interface lives
|
|
814
|
-
* in a transitive package; using `any` keeps us decoupled.
|
|
815
|
-
*
|
|
816
|
-
* Exported so the console's log contract is testable, and so an app wiring its
|
|
817
|
-
* own `ObjectStackClient` gets the same identified failures.
|
|
818
|
-
*/
|
|
819
|
-
export function createQuietHttpLogger(): any {
|
|
820
|
-
const isExpected404 = (meta?: Record<string, any>): boolean => {
|
|
821
|
-
if (!meta || typeof meta !== 'object') return false;
|
|
822
|
-
if (meta.status === 404 || meta.statusCode === 404) return true;
|
|
823
|
-
const errBody = meta.error;
|
|
824
|
-
if (errBody && typeof errBody === 'object') {
|
|
825
|
-
const code = (errBody as Record<string, unknown>).code;
|
|
826
|
-
if (errorCodeIsAnyOf({ code }, ['OBJECT_NOT_FOUND', 'RECORD_NOT_FOUND'])) return true;
|
|
827
|
-
}
|
|
828
|
-
return false;
|
|
829
|
-
};
|
|
830
|
-
const logger: any = {
|
|
831
|
-
debug: (message: string, meta?: Record<string, any>) =>
|
|
832
|
-
console.debug(message, meta ?? ''),
|
|
833
|
-
info: (message: string, meta?: Record<string, any>) =>
|
|
834
|
-
console.info(message, meta ?? ''),
|
|
835
|
-
warn: (message: string, meta?: Record<string, any>) =>
|
|
836
|
-
console.warn(message, meta ?? ''),
|
|
837
|
-
error: (message: string, error?: Error, meta?: Record<string, any>) => {
|
|
838
|
-
if (isExpected404(meta)) {
|
|
839
|
-
console.debug(
|
|
840
|
-
`[ObjectStack] ${formatHttpFailureMessage(message, meta) ?? message} (suppressed expected 404)`,
|
|
841
|
-
meta,
|
|
842
|
-
);
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
845
|
-
console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? '', meta ?? '');
|
|
846
|
-
},
|
|
847
|
-
fatal: (message: string, error?: Error, meta?: Record<string, any>) =>
|
|
848
|
-
console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? '', meta ?? ''),
|
|
849
|
-
log: (message: string, ...args: any[]) => console.log(message, ...args),
|
|
850
|
-
child: () => logger,
|
|
851
|
-
withTrace: () => logger,
|
|
852
|
-
};
|
|
853
|
-
return logger;
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
/**
|
|
857
|
-
* Connection state for monitoring
|
|
858
|
-
*/
|
|
859
|
-
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error';
|
|
860
|
-
|
|
861
|
-
/**
|
|
862
|
-
* Connection state change event
|
|
863
|
-
*/
|
|
864
|
-
export interface ConnectionStateEvent {
|
|
865
|
-
state: ConnectionState;
|
|
866
|
-
timestamp: number;
|
|
867
|
-
error?: Error;
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
/**
|
|
871
|
-
* Batch operation progress event
|
|
872
|
-
*/
|
|
873
|
-
export interface BatchProgressEvent {
|
|
874
|
-
operation: 'create' | 'update' | 'delete';
|
|
875
|
-
total: number;
|
|
876
|
-
completed: number;
|
|
877
|
-
failed: number;
|
|
878
|
-
percentage: number;
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
/**
|
|
882
|
-
* Event listener type for connection state changes
|
|
883
|
-
*/
|
|
884
|
-
export type ConnectionStateListener = (event: ConnectionStateEvent) => void;
|
|
885
|
-
|
|
886
|
-
/**
|
|
887
|
-
* Event listener type for batch operation progress
|
|
888
|
-
*/
|
|
889
|
-
export type BatchProgressListener = (event: BatchProgressEvent) => void;
|
|
890
|
-
|
|
891
|
-
/**
|
|
892
|
-
* One server-reported write-strip: caller-supplied fields the backend LEGALLY
|
|
893
|
-
* removed from a write before persisting (a non-system caller cannot seed a
|
|
894
|
-
* `readonly` field, a `readonlyWhen` predicate locked it, etc.).
|
|
895
|
-
*
|
|
896
|
-
* THE spec type, re-exported (objectui#3160, objectstack#4115 ledger batch 6).
|
|
897
|
-
* Until then this was a hand copy whose comment said it "mirrors the framework
|
|
898
|
-
* `DroppedFieldsEvent` (spec `DroppedFieldsEventSchema`) structurally so we
|
|
899
|
-
* don't pin a client type version", with `reason` widened from the spec's
|
|
900
|
-
* `'readonly' | 'readonly_when'` to bare `string` "for forward-compatibility
|
|
901
|
-
* with reasons added server-side". Both halves of that reasoning are the
|
|
902
|
-
* failure mode this ledger exists to remove: the spec IS the client type
|
|
903
|
-
* version, and a consumer-side widening of a producer's enum is precisely the
|
|
904
|
-
* lenient fallback AGENTS.md #12 bans — it deletes the only compile-time signal
|
|
905
|
-
* that would tell `AdapterProvider`'s toast wording (which branches on
|
|
906
|
-
* `readonly_when`) that a new reason had appeared.
|
|
907
|
-
*/
|
|
908
|
-
export type { DroppedFieldsEvent };
|
|
909
|
-
|
|
910
|
-
/**
|
|
911
|
-
* Emitted after a create/update whose response carried `droppedFields`
|
|
912
|
-
* (framework #3431/#3455). The write SUCCEEDED — this is a warning that some
|
|
913
|
-
* supplied fields never landed, so the UI can tell the user rather than let it
|
|
914
|
-
* pass silently. Subscribe via {@link ObjectStackAdapter.onWriteWarning}.
|
|
915
|
-
*/
|
|
916
|
-
export interface WriteWarningEvent {
|
|
917
|
-
operation: 'create' | 'update';
|
|
918
|
-
resource: string;
|
|
919
|
-
id?: string | number;
|
|
920
|
-
droppedFields: DroppedFieldsEvent[];
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
/** Event listener type for write-warning (dropped-fields) events. */
|
|
924
|
-
export type WriteWarningListener = (event: WriteWarningEvent) => void;
|
|
925
|
-
|
|
926
|
-
// Re-export FileUploadResult from types for consumers
|
|
927
|
-
export type { FileUploadResult } from '@object-ui/types';
|
|
928
|
-
|
|
929
|
-
/**
|
|
930
|
-
* Deterministic JSON.stringify with sorted object keys, used to build cache
|
|
931
|
-
* keys for in-flight request coalescing. Produces identical output for
|
|
932
|
-
* `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` so callers that build params in
|
|
933
|
-
* different orders still hit the same key.
|
|
934
|
-
*/
|
|
935
|
-
function stableStringify(value: unknown): string {
|
|
936
|
-
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
937
|
-
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
938
|
-
const obj = value as Record<string, unknown>;
|
|
939
|
-
const keys = Object.keys(obj).sort();
|
|
940
|
-
return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`;
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
/**
|
|
944
|
-
* Whether two values are the SAME as far as the wire is concerned — used to
|
|
945
|
-
* tell a write-strip that lost something from one that lost nothing
|
|
946
|
-
* (objectui#3484).
|
|
947
|
-
*
|
|
948
|
-
* Deliberately strict: `1` and `'1'` are NOT the same here. A false "these are
|
|
949
|
-
* equal" silently swallows a warning about a value the user really did lose,
|
|
950
|
-
* which is the worse of the two errors; a false "these differ" only warns
|
|
951
|
-
* about a no-op. `null` and `undefined` both mean "empty" and do match.
|
|
952
|
-
*/
|
|
953
|
-
function sameWireValue(a: unknown, b: unknown): boolean {
|
|
954
|
-
if (a === b) return true;
|
|
955
|
-
const aEmpty = a === null || a === undefined;
|
|
956
|
-
const bEmpty = b === null || b === undefined;
|
|
957
|
-
if (aEmpty || bEmpty) return aEmpty && bEmpty;
|
|
958
|
-
return stableStringify(a) === stableStringify(b);
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
/**
|
|
962
|
-
* Drop the fields whose strip changed nothing — the caller supplied exactly
|
|
963
|
-
* the value the record already holds (objectui#3484).
|
|
964
|
-
*
|
|
965
|
-
* The server is not wrong to report those: the client DID send the key and the
|
|
966
|
-
* engine DID refuse to write it, so `droppedFields` is an accurate account of
|
|
967
|
-
* the request. But "was not saved" is only news when something was actually
|
|
968
|
-
* lost, and the console's edit form used to round-trip the whole record —
|
|
969
|
-
* including fields it had itself rendered as read-only text — so every save of
|
|
970
|
-
* a state-locked record raised a warning listing fields the user never touched
|
|
971
|
-
* and whose values never changed.
|
|
972
|
-
*
|
|
973
|
-
* A field is kept (still warned about) whenever the no-op cannot be PROVEN:
|
|
974
|
-
* the response echoed no record, or that record does not carry the key.
|
|
975
|
-
*
|
|
976
|
-
* Returns entries with empty `fields` removed; an all-no-op event list comes
|
|
977
|
-
* back empty, which suppresses the warning entirely.
|
|
978
|
-
*/
|
|
979
|
-
function withoutNoOpDrops(
|
|
980
|
-
droppedFields: DroppedFieldsEvent[],
|
|
981
|
-
sent: Record<string, unknown> | undefined | null,
|
|
982
|
-
stored: Record<string, unknown> | undefined | null,
|
|
983
|
-
): DroppedFieldsEvent[] {
|
|
984
|
-
if (!sent || !stored || typeof sent !== 'object' || typeof stored !== 'object') {
|
|
985
|
-
return droppedFields;
|
|
986
|
-
}
|
|
987
|
-
const out: DroppedFieldsEvent[] = [];
|
|
988
|
-
for (const e of droppedFields) {
|
|
989
|
-
const kept = e.fields.filter((f) => {
|
|
990
|
-
if (!Object.prototype.hasOwnProperty.call(sent, f)) return true;
|
|
991
|
-
if (!Object.prototype.hasOwnProperty.call(stored, f)) return true;
|
|
992
|
-
return !sameWireValue(
|
|
993
|
-
(sent as Record<string, unknown>)[f],
|
|
994
|
-
(stored as Record<string, unknown>)[f],
|
|
995
|
-
);
|
|
996
|
-
});
|
|
997
|
-
if (kept.length > 0) out.push({ ...e, fields: kept });
|
|
998
|
-
}
|
|
999
|
-
return out;
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
/**
|
|
1003
|
-
* Resolve which object a `type='view'` metadata item belongs to.
|
|
1004
|
-
*
|
|
1005
|
-
* The metadata index is name-only, not field-typed: `GET /api/v1/meta/view`
|
|
1006
|
-
* accepts `?package=` and `?preview=draft` and nothing else (measured on
|
|
1007
|
-
* framework `packages/rest/src/rest-server.ts` — the `GET /meta/:type`
|
|
1008
|
-
* handler — and on `client.meta.getItems(type, { packageId })`). So every
|
|
1009
|
-
* reader of the view namespace enumerates `type='view'` once and narrows to
|
|
1010
|
-
* one object HERE, client-side.
|
|
1011
|
-
*
|
|
1012
|
-
* ONE spelling, one place, deliberately: {@link ObjectStackAdapter.listViews}
|
|
1013
|
-
* and {@link ObjectStackAdapter.listViewOverrides} read the same rows out of
|
|
1014
|
-
* the same namespace, and two private copies of "which object is this?" is a
|
|
1015
|
-
* drift waiting to happen — the switcher showing a view whose override the
|
|
1016
|
-
* grid cannot find, or the reverse.
|
|
1017
|
-
*
|
|
1018
|
-
* `object` is the identity field the write path stamps (and that the
|
|
1019
|
-
* framework's overlay heals onto identity-less personalization rows —
|
|
1020
|
-
* objectstack#2555); `data.object` is the config's data-provider target and
|
|
1021
|
-
* `objectName` the legacy artifact spelling.
|
|
1022
|
-
*
|
|
1023
|
-
* **Exported** since objectui#4373, for the same one-spelling reason: a writer
|
|
1024
|
-
* outside this module that holds a view BODY but not its object name (app-shell's
|
|
1025
|
-
* `MetadataService`) needs the object to name the keys
|
|
1026
|
-
* {@link ObjectStackAdapter.invalidateViewKeys} drops, and a fourth private copy
|
|
1027
|
-
* of "which object is this?" is exactly the drift this accessor exists to
|
|
1028
|
-
* prevent. Identity only — it names no cache key.
|
|
1029
|
-
*/
|
|
1030
|
-
export function viewItemObjectName(item: any): string | undefined {
|
|
1031
|
-
// Handle both bare view spec and `{list: {...}}` artifact wrapper
|
|
1032
|
-
const spec = item?.list ?? item;
|
|
1033
|
-
return spec?.data?.object ?? spec?.object ?? spec?.objectName;
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
/**
|
|
1037
|
-
* Unwrap a `?state=draft` view read into its bare body, or `null` when there
|
|
1038
|
-
* is nothing pending (#4139).
|
|
1039
|
-
*
|
|
1040
|
-
* The framework answers EVERY single-item read — draft or published — in a
|
|
1041
|
-
* `{type, name, item}` envelope. `MetadataClient.get()` unwraps it at the
|
|
1042
|
-
* client boundary (objectui#4271) while `getDraft()` deliberately hands the
|
|
1043
|
-
* envelope back, so this helper stays tolerant of both: the call below reaches
|
|
1044
|
-
* it through `get()` and therefore already holds the body, and the passthrough
|
|
1045
|
-
* limb keeps it correct for an envelope arriving by any other route. An empty
|
|
1046
|
-
* body is normalized to `null` so the caller's "is this view draft-backed?"
|
|
1047
|
-
* test is a plain truthiness check. Mirrors app-shell's `unwrapDraftBody`
|
|
1048
|
-
* (ADR-0034 seam); the two live apart because the seam sits above this
|
|
1049
|
-
* adapter, not beside it.
|
|
1050
|
-
*/
|
|
1051
|
-
function unwrapViewDraft(resp: unknown): Record<string, any> | null {
|
|
1052
|
-
if (!resp || typeof resp !== 'object') return null;
|
|
1053
|
-
const env = resp as Record<string, any>;
|
|
1054
|
-
const body = 'item' in env ? env.item : env;
|
|
1055
|
-
if (!body || typeof body !== 'object') return null;
|
|
1056
|
-
// Same `{list: {...}}` artifact wrapper the published read unwraps.
|
|
1057
|
-
const spec = body.list ?? body;
|
|
1058
|
-
if (!spec || typeof spec !== 'object') return null;
|
|
1059
|
-
return Object.keys(spec).length > 0 ? (spec as Record<string, any>) : null;
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
/**
|
|
1063
|
-
* `deleteView`'s receipt shapes, RE-EXPORTED from `@object-ui/types` (#4564).
|
|
1064
|
-
*
|
|
1065
|
-
* #4479 first declared both here, because this adapter is where the widened
|
|
1066
|
-
* receipt is produced. That left the shared `DataSource.deleteView?` unable to
|
|
1067
|
-
* describe it: the dependency runs this package -> `@object-ui/types` and never
|
|
1068
|
-
* the other way, so the interface kept the narrow `{ deleted: boolean }` and a
|
|
1069
|
-
* consumer reaching this adapter THROUGH `DataSource` was handed a type with
|
|
1070
|
-
* the per-home outcomes already discarded. Nothing failed to compile — a wider
|
|
1071
|
-
* return is assignable to a narrower declaration — which is precisely why the
|
|
1072
|
-
* gap was silent until #4564 measured it.
|
|
1073
|
-
*
|
|
1074
|
-
* So the canonical declarations now live beside the interface they belong to,
|
|
1075
|
-
* in `packages/types/src/data.ts`, and this package re-exports them under the
|
|
1076
|
-
* SAME names: every importer PR #4562 left pointing at
|
|
1077
|
-
* `@object-ui/data-objectstack` keeps compiling, and now gets the very type the
|
|
1078
|
-
* shared contract speaks rather than a structural twin of it. Pinned by
|
|
1079
|
-
* `deleteViewContract.types.test.ts`, which asserts type IDENTITY — mutual
|
|
1080
|
-
* assignability would have been satisfied by a copy that was already drifting.
|
|
1081
|
-
*/
|
|
1082
|
-
export type { DeleteViewResult, ViewHomeDeleteOutcome } from '@object-ui/types';
|
|
1083
|
-
|
|
1084
|
-
/**
|
|
1085
|
-
* Read one delete receipt into a {@link ViewHomeDeleteOutcome}.
|
|
1086
|
-
*
|
|
1087
|
-
* The `deleted ?? reset ?? true` ladder is carried over verbatim from the
|
|
1088
|
-
* single-call `deleteView` this replaced, so a server that answers a shape
|
|
1089
|
-
* with neither key is read exactly as it was before (#4479): the framework
|
|
1090
|
-
* sends `reset`, the SDK's typed metadata shape names `deleted`, and the
|
|
1091
|
-
* final `true` is the "it answered 2xx and said nothing" default.
|
|
1092
|
-
*/
|
|
1093
|
-
function readViewDeleteReceipt(result: unknown): ViewHomeDeleteOutcome {
|
|
1094
|
-
const r = (result ?? undefined) as Record<string, any> | undefined;
|
|
1095
|
-
return {
|
|
1096
|
-
removed: !!(r?.deleted ?? r?.reset ?? true),
|
|
1097
|
-
...(typeof r?.reset === 'boolean' ? { reset: r.reset } : {}),
|
|
1098
|
-
...(typeof r?.message === 'string' ? { message: r.message } : {}),
|
|
1099
|
-
};
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
/**
|
|
1103
|
-
* Merge a partial view patch onto the CURRENT view document.
|
|
1104
|
-
*
|
|
1105
|
-
* ADR-0005 overlay rows store the *full* view document, so a partial update is
|
|
1106
|
-
* a read-merge-write cycle and the merge must start from real current state —
|
|
1107
|
-
* merging onto `{}` yields a `{label, name, object}` fragment the server
|
|
1108
|
-
* rejects (422), which is exactly how a rename used to be lost (#4139).
|
|
1109
|
-
*
|
|
1110
|
-
* `name` is forced to the URL segment so the row key and `body.name` agree
|
|
1111
|
-
* (#2767 P1), and `object` falls back through the two spellings a stored view
|
|
1112
|
-
* may carry before defaulting to the caller's object.
|
|
1113
|
-
*/
|
|
1114
|
-
function mergeViewPatch(
|
|
1115
|
-
current: Record<string, any>,
|
|
1116
|
-
partial: Record<string, any>,
|
|
1117
|
-
viewName: string,
|
|
1118
|
-
objectName: string,
|
|
1119
|
-
): Record<string, any> {
|
|
1120
|
-
return {
|
|
1121
|
-
...current,
|
|
1122
|
-
...partial,
|
|
1123
|
-
name: viewName,
|
|
1124
|
-
object: current?.object || current?.data?.object || objectName,
|
|
1125
|
-
};
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
/**
|
|
1129
|
-
* ObjectStack Data Source Adapter
|
|
1130
|
-
*
|
|
1131
|
-
* Bridges the ObjectStack Client SDK with the ObjectUI DataSource interface.
|
|
1132
|
-
* This allows Object UI applications to seamlessly integrate with ObjectStack
|
|
1133
|
-
* backends while maintaining the universal DataSource abstraction.
|
|
1134
|
-
*
|
|
1135
|
-
* @example
|
|
1136
|
-
* ```typescript
|
|
1137
|
-
* import { ObjectStackAdapter } from '@object-ui/data-objectstack';
|
|
1138
|
-
*
|
|
1139
|
-
* const dataSource = new ObjectStackAdapter({
|
|
1140
|
-
* baseUrl: 'https://api.example.com',
|
|
1141
|
-
* token: 'your-api-token',
|
|
1142
|
-
* autoReconnect: true,
|
|
1143
|
-
* maxReconnectAttempts: 5
|
|
1144
|
-
* });
|
|
1145
|
-
*
|
|
1146
|
-
* // Monitor connection state
|
|
1147
|
-
* dataSource.onConnectionStateChange((event) => {
|
|
1148
|
-
* console.log('Connection state:', event.state);
|
|
1149
|
-
* });
|
|
1150
|
-
*
|
|
1151
|
-
* const users = await dataSource.find('users', {
|
|
1152
|
-
* $filter: { status: 'active' },
|
|
1153
|
-
* $top: 10
|
|
1154
|
-
* });
|
|
1155
|
-
* ```
|
|
1156
|
-
*/
|
|
1157
|
-
export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
1158
|
-
private client: ObjectStackClient;
|
|
1159
|
-
private connected: boolean = false;
|
|
1160
|
-
private connectPromise: Promise<void> | null = null;
|
|
1161
|
-
private metadataCache: MetadataCache;
|
|
1162
|
-
private connectionState: ConnectionState = 'disconnected';
|
|
1163
|
-
private connectionStateListeners: ConnectionStateListener[] = [];
|
|
1164
|
-
private batchProgressListeners: BatchProgressListener[] = [];
|
|
1165
|
-
private autoReconnect: boolean;
|
|
1166
|
-
private maxReconnectAttempts: number;
|
|
1167
|
-
private reconnectDelay: number;
|
|
1168
|
-
private reconnectAttempts: number = 0;
|
|
1169
|
-
private baseUrl: string;
|
|
1170
|
-
private token?: string;
|
|
1171
|
-
/** One "analytics capability is missing" console line per adapter, not per widget. */
|
|
1172
|
-
private analyticsCapabilityWarned = false;
|
|
1173
|
-
private fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
1174
|
-
// In-flight find() requests keyed by resource + serialized params.
|
|
1175
|
-
// Coalesces concurrent identical reads (e.g. React StrictMode double-mount,
|
|
1176
|
-
// multiple sibling components requesting the same dataset on first paint)
|
|
1177
|
-
// into a single network round trip.
|
|
1178
|
-
private inflightFinds = new Map<string, Promise<QueryResult<T>>>();
|
|
1179
|
-
// Resources that have responded 404 at least once (collection not installed
|
|
1180
|
-
// on this backend). Subsequent find() calls short-circuit to an empty result
|
|
1181
|
-
// so optional collections like sys_presence don't hammer the server with
|
|
1182
|
-
// failing requests on every record open / panel render.
|
|
1183
|
-
private missingResources = new Set<string>();
|
|
1184
|
-
// Set once the server has told us it can't do a cross-object transactional
|
|
1185
|
-
// batch (the client SDK's data.batchTransaction threw HTTP 404/405/501). After
|
|
1186
|
-
// that, batchTransaction skips the SDK call and serves every call via the
|
|
1187
|
-
// client-side emulation — the non-atomic fallback lives HERE, isolated to the
|
|
1188
|
-
// one adapter that has to cope with a backend lacking server atomicity (#2679).
|
|
1189
|
-
private batchUnsupported = false;
|
|
1190
|
-
// The server's declared cross-object atomic-batch capability, read from
|
|
1191
|
-
// discovery at connect() (framework #3298 / objectui #2693). `true` → the
|
|
1192
|
-
// backend GUARANTEES an atomic `/batch`, so batchTransaction trusts it and
|
|
1193
|
-
// never degrades to the non-atomic emulation (any failure surfaces as a real
|
|
1194
|
-
// error). `false` or `undefined` (capability absent → backend predates #3298)
|
|
1195
|
-
// → keep the legacy runtime-probe + emulation fallback so a save is still
|
|
1196
|
-
// possible; dropping it there would turn "saves, less safe" into "no save
|
|
1197
|
-
// path" on older backends (#2679 compatibility constraint).
|
|
1198
|
-
private atomicBatchCapability: boolean | undefined;
|
|
1199
|
-
// Subscribers registered via onMutation(). Emitted after each successful
|
|
1200
|
-
// create/update/delete so data-bound views (ListView, ObjectView, kanban,
|
|
1201
|
-
// calendar) auto-refresh — the interface ListView relies on to reflect
|
|
1202
|
-
// inline-edit "Save All" writes without a manual reload.
|
|
1203
|
-
private mutationListeners = new Set<(event: DataSourceMutationEvent<T>) => void>();
|
|
1204
|
-
|
|
1205
|
-
// Subscribers registered via onWriteWarning(). Emitted after a create/update
|
|
1206
|
-
// whose response carried `droppedFields` (framework #3431/#3455) so the app
|
|
1207
|
-
// shell can surface a toast instead of the strip passing silently.
|
|
1208
|
-
private writeWarningListeners = new Set<WriteWarningListener>();
|
|
1209
|
-
|
|
1210
|
-
// Subscribers registered via onSaveAdvisory(). Emitted after a metadata save
|
|
1211
|
-
// through THIS adapter's `ObjectStackClient` whose 200 carried a non-empty
|
|
1212
|
-
// `advisories` array (#4237; backend objectstack#7435). Sibling of the set
|
|
1213
|
-
// above in every respect except which door produced the event: that one is
|
|
1214
|
-
// record CRUD, this one is the metadata save door.
|
|
1215
|
-
private saveAdvisoryListeners = new Set<MetadataSaveAdvisoryListener>();
|
|
1216
|
-
|
|
1217
|
-
constructor(config: {
|
|
1218
|
-
baseUrl: string;
|
|
1219
|
-
token?: string;
|
|
1220
|
-
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
1221
|
-
cache?: {
|
|
1222
|
-
maxSize?: number;
|
|
1223
|
-
ttl?: number;
|
|
1224
|
-
};
|
|
1225
|
-
autoReconnect?: boolean;
|
|
1226
|
-
maxReconnectAttempts?: number;
|
|
1227
|
-
reconnectDelay?: number;
|
|
1228
|
-
}) {
|
|
1229
|
-
// Inject a quiet logger that demotes expected 404s ("HTTP request failed"
|
|
1230
|
-
// from probing optional collections like sys_presence/sys_activity) to
|
|
1231
|
-
// debug() so they don't pollute the browser console. Other log levels are
|
|
1232
|
-
// forwarded to the standard console.
|
|
1233
|
-
this.client = new ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
|
|
1234
|
-
// #4237 — one emitter for every metadata save this adapter's client makes,
|
|
1235
|
-
// installed the moment the client exists so no save can precede it.
|
|
1236
|
-
this.installSaveAdvisoryInterceptor();
|
|
1237
|
-
this.metadataCache = new MetadataCache(config.cache);
|
|
1238
|
-
this.autoReconnect = config.autoReconnect ?? true;
|
|
1239
|
-
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3;
|
|
1240
|
-
this.reconnectDelay = config.reconnectDelay ?? 1000;
|
|
1241
|
-
this.baseUrl = config.baseUrl;
|
|
1242
|
-
this.token = config.token;
|
|
1243
|
-
this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
/**
|
|
1247
|
-
* Ensure the client is connected to the server.
|
|
1248
|
-
* Call this before making requests or it will auto-connect on first request.
|
|
1249
|
-
*/
|
|
1250
|
-
async connect(): Promise<void> {
|
|
1251
|
-
if (this.connected) return;
|
|
1252
|
-
// Dedupe concurrent connect() calls — without this, every component
|
|
1253
|
-
// that mounts on first paint can trigger an independent discovery
|
|
1254
|
-
// request before the first one completes.
|
|
1255
|
-
if (this.connectPromise) return this.connectPromise;
|
|
1256
|
-
|
|
1257
|
-
this.setConnectionState('connecting');
|
|
1258
|
-
this.connectPromise = (async () => {
|
|
1259
|
-
try {
|
|
1260
|
-
// Use the module-level discovery cache so multiple adapter instances
|
|
1261
|
-
// (or React StrictMode double-mounts) at the same baseUrl share a
|
|
1262
|
-
// single network round trip. We inject the result into the client's
|
|
1263
|
-
// private `discoveryInfo` field to avoid client.connect() re-fetching.
|
|
1264
|
-
const baseUrl = this.baseUrl || '';
|
|
1265
|
-
const discoveryUrl = baseUrl
|
|
1266
|
-
? `${baseUrl.replace(/\/$/, '')}/api/v1/discovery`
|
|
1267
|
-
: '/api/v1/discovery';
|
|
1268
|
-
|
|
1269
|
-
const data = await getSharedDiscovery(baseUrl, async () => {
|
|
1270
|
-
const res = await this.fetchImpl(discoveryUrl, {
|
|
1271
|
-
method: 'GET',
|
|
1272
|
-
headers: this.token
|
|
1273
|
-
? { Authorization: `Bearer ${this.token}` }
|
|
1274
|
-
: undefined,
|
|
1275
|
-
});
|
|
1276
|
-
if (!res.ok) {
|
|
1277
|
-
throw new Error(`discovery ${res.status} ${res.statusText}`);
|
|
1278
|
-
}
|
|
1279
|
-
const body = await res.json();
|
|
1280
|
-
return body && typeof body.success === 'boolean' && 'data' in body
|
|
1281
|
-
? body.data
|
|
1282
|
-
: body;
|
|
1283
|
-
});
|
|
1284
|
-
|
|
1285
|
-
// Prime the underlying client's cached discovery so capability/route
|
|
1286
|
-
// helpers continue to work without a redundant fetch.
|
|
1287
|
-
(this.client as unknown as { discoveryInfo?: unknown }).discoveryInfo = data;
|
|
1288
|
-
|
|
1289
|
-
// Record the declared cross-object atomic-batch capability (#3298) so
|
|
1290
|
-
// batchTransaction can decide declaratively at call time whether it may
|
|
1291
|
-
// trust server atomicity instead of runtime-probing 404/405/501.
|
|
1292
|
-
this.atomicBatchCapability = readTransactionalBatchCapability(data);
|
|
1293
|
-
|
|
1294
|
-
this.connected = true;
|
|
1295
|
-
this.reconnectAttempts = 0;
|
|
1296
|
-
this.setConnectionState('connected');
|
|
1297
|
-
} catch (error: unknown) {
|
|
1298
|
-
const errorMessage = error instanceof Error ? error.message : 'Failed to connect to ObjectStack server';
|
|
1299
|
-
const connectionError = new ConnectionError(
|
|
1300
|
-
errorMessage,
|
|
1301
|
-
undefined,
|
|
1302
|
-
{ originalError: error }
|
|
1303
|
-
);
|
|
1304
|
-
|
|
1305
|
-
this.setConnectionState('error', connectionError);
|
|
1306
|
-
|
|
1307
|
-
// Attempt auto-reconnect if enabled
|
|
1308
|
-
if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
1309
|
-
await this.attemptReconnect();
|
|
1310
|
-
} else {
|
|
1311
|
-
throw connectionError;
|
|
1312
|
-
}
|
|
1313
|
-
} finally {
|
|
1314
|
-
this.connectPromise = null;
|
|
1315
|
-
}
|
|
1316
|
-
})();
|
|
1317
|
-
return this.connectPromise;
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
/**
|
|
1321
|
-
* Attempt to reconnect to the server with exponential backoff
|
|
1322
|
-
*/
|
|
1323
|
-
private async attemptReconnect(): Promise<void> {
|
|
1324
|
-
this.reconnectAttempts++;
|
|
1325
|
-
this.setConnectionState('reconnecting');
|
|
1326
|
-
|
|
1327
|
-
// Exponential backoff: delay * 2^(attempts-1)
|
|
1328
|
-
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
1329
|
-
|
|
1330
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
1331
|
-
|
|
1332
|
-
this.connected = false;
|
|
1333
|
-
await this.connect();
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
/**
|
|
1337
|
-
* Get the current connection state
|
|
1338
|
-
*/
|
|
1339
|
-
getConnectionState(): ConnectionState {
|
|
1340
|
-
return this.connectionState;
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
/**
|
|
1344
|
-
* Check if the adapter is currently connected
|
|
1345
|
-
*/
|
|
1346
|
-
isConnected(): boolean {
|
|
1347
|
-
return this.connected && this.connectionState === 'connected';
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
/**
|
|
1351
|
-
* Register a listener for connection state changes
|
|
1352
|
-
*/
|
|
1353
|
-
onConnectionStateChange(listener: ConnectionStateListener): () => void {
|
|
1354
|
-
this.connectionStateListeners.push(listener);
|
|
1355
|
-
|
|
1356
|
-
// Return unsubscribe function
|
|
1357
|
-
return () => {
|
|
1358
|
-
const index = this.connectionStateListeners.indexOf(listener);
|
|
1359
|
-
if (index > -1) {
|
|
1360
|
-
this.connectionStateListeners.splice(index, 1);
|
|
1361
|
-
}
|
|
1362
|
-
};
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
/**
|
|
1366
|
-
* Register a listener for batch operation progress
|
|
1367
|
-
*/
|
|
1368
|
-
onBatchProgress(listener: BatchProgressListener): () => void {
|
|
1369
|
-
this.batchProgressListeners.push(listener);
|
|
1370
|
-
|
|
1371
|
-
// Return unsubscribe function
|
|
1372
|
-
return () => {
|
|
1373
|
-
const index = this.batchProgressListeners.indexOf(listener);
|
|
1374
|
-
if (index > -1) {
|
|
1375
|
-
this.batchProgressListeners.splice(index, 1);
|
|
1376
|
-
}
|
|
1377
|
-
};
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
/**
|
|
1381
|
-
* Set connection state and notify listeners
|
|
1382
|
-
*/
|
|
1383
|
-
private setConnectionState(state: ConnectionState, error?: Error): void {
|
|
1384
|
-
this.connectionState = state;
|
|
1385
|
-
|
|
1386
|
-
const event: ConnectionStateEvent = {
|
|
1387
|
-
state,
|
|
1388
|
-
timestamp: Date.now(),
|
|
1389
|
-
error,
|
|
1390
|
-
};
|
|
1391
|
-
|
|
1392
|
-
this.connectionStateListeners.forEach(listener => {
|
|
1393
|
-
try {
|
|
1394
|
-
listener(event);
|
|
1395
|
-
} catch (err) {
|
|
1396
|
-
console.error('Error in connection state listener:', err);
|
|
1397
|
-
}
|
|
1398
|
-
});
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
/**
|
|
1402
|
-
* Emit batch progress event to listeners
|
|
1403
|
-
*/
|
|
1404
|
-
private emitBatchProgress(event: BatchProgressEvent): void {
|
|
1405
|
-
this.batchProgressListeners.forEach(listener => {
|
|
1406
|
-
try {
|
|
1407
|
-
listener(event);
|
|
1408
|
-
} catch (err) {
|
|
1409
|
-
console.error('Error in batch progress listener:', err);
|
|
1410
|
-
}
|
|
1411
|
-
});
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
/**
|
|
1415
|
-
* Find multiple records with query parameters.
|
|
1416
|
-
* Converts OData-style params to ObjectStack query options.
|
|
1417
|
-
*/
|
|
1418
|
-
async find(resource: string, params?: QueryParams): Promise<QueryResult<T>> {
|
|
1419
|
-
// Short-circuit when this resource has previously responded 404 — the
|
|
1420
|
-
// collection isn't installed on this backend. Callers (AppHeader,
|
|
1421
|
-
// RecordDetailView, …) treat empty data as "feature unavailable".
|
|
1422
|
-
if (this.missingResources.has(resource)) {
|
|
1423
|
-
return { data: [], total: 0 } as QueryResult<T>;
|
|
1424
|
-
}
|
|
1425
|
-
const key = `${resource}::${stableStringify(params)}`;
|
|
1426
|
-
const existing = this.inflightFinds.get(key);
|
|
1427
|
-
if (existing) return existing;
|
|
1428
|
-
|
|
1429
|
-
const promise = (async () => {
|
|
1430
|
-
await this.connect();
|
|
1431
|
-
|
|
1432
|
-
// When $expand is requested, use a raw GET request to the REST API with
|
|
1433
|
-
// `populate` as a URL query param. The server's REST plugin routes
|
|
1434
|
-
// GET /data/:object to protocol.findData({ object, query: req.query }),
|
|
1435
|
-
// which parses `populate` (comma-separated) into an array for lookup expansion.
|
|
1436
|
-
// We use a raw request because the client SDK's data.find() QueryOptions
|
|
1437
|
-
// interface does not include populate/expand fields.
|
|
1438
|
-
if ((params?.$expand && params.$expand.length > 0)
|
|
1439
|
-
|| (params?.$search != null && String(params.$search).trim() !== '')) {
|
|
1440
|
-
// The client SDK's data.find() QueryOptions drops `$search`; route through
|
|
1441
|
-
// the raw GET so the term reaches protocol.findData → the metadata-driven
|
|
1442
|
-
// search executor (ADR-0061).
|
|
1443
|
-
const result = await this.rawFindWithPopulate(resource, params);
|
|
1444
|
-
return this.normalizeQueryResult(result, params);
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
const queryOptions = this.convertQueryParams(params);
|
|
1448
|
-
try {
|
|
1449
|
-
const result: unknown = await this.client.data.find<T>(resource, queryOptions);
|
|
1450
|
-
return this.normalizeQueryResult(result, params);
|
|
1451
|
-
} catch (err) {
|
|
1452
|
-
// An `enable`-block denial is NOT a missing collection. The object
|
|
1453
|
-
// exists and the server is deliberately refusing to expose it, forever
|
|
1454
|
-
// and for everyone — degrading that to an empty result set tells the
|
|
1455
|
-
// user "you have no records" about a page that can never hold any
|
|
1456
|
-
// (objectui#4408). Rethrow so the surface can say what happened; the
|
|
1457
|
-
// `missingResources` memo must not absorb it either, or the very first
|
|
1458
|
-
// denial would silently pin every later call to empty.
|
|
1459
|
-
if (!isApiAccessDeniedError(err) && is404Error(err)) {
|
|
1460
|
-
// Mark the resource so subsequent calls don't repeat the 404.
|
|
1461
|
-
this.missingResources.add(resource);
|
|
1462
|
-
return { data: [], total: 0 } as QueryResult<T>;
|
|
1463
|
-
}
|
|
1464
|
-
throw err;
|
|
1465
|
-
}
|
|
1466
|
-
})();
|
|
1467
|
-
|
|
1468
|
-
this.inflightFinds.set(key, promise);
|
|
1469
|
-
// Use `.then(cleanup, cleanup)` instead of `.finally(cleanup)`. `.finally`
|
|
1470
|
-
// returns a new chained promise that re-raises the rejection, and because
|
|
1471
|
-
// we don't return that chain, Node/browsers see it as an unhandled
|
|
1472
|
-
// rejection — flooding DevTools when callers handle the original `promise`
|
|
1473
|
-
// via `.catch()` (e.g. AppHeader probing optional sys_presence/sys_activity).
|
|
1474
|
-
const cleanup = () => {
|
|
1475
|
-
// Only clear if the entry still points at this promise; a later call
|
|
1476
|
-
// that started after settle may have already replaced it.
|
|
1477
|
-
if (this.inflightFinds.get(key) === promise) {
|
|
1478
|
-
this.inflightFinds.delete(key);
|
|
1479
|
-
}
|
|
1480
|
-
};
|
|
1481
|
-
promise.then(cleanup, cleanup);
|
|
1482
|
-
return promise;
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
/**
|
|
1486
|
-
* Full-text search across every searchable object in a single round-trip.
|
|
1487
|
-
*
|
|
1488
|
-
* Hits `GET /api/v1/search?q=`, the platform's global search endpoint served
|
|
1489
|
-
* by the registered search service (the pinyin full-text plugin) and backed
|
|
1490
|
-
* by `metadata-protocol`'s `searchAll`. Unlike `find(resource, { $search })`
|
|
1491
|
-
* — a per-object metadata-driven search (ADR-0061) — this consults the search
|
|
1492
|
-
* index and ranks hits across objects, so it surfaces records the per-object
|
|
1493
|
-
* fanout misses. Global affordances (⌘K command palette, search page) prefer
|
|
1494
|
-
* this path (framework #3371).
|
|
1495
|
-
*
|
|
1496
|
-
* Returns `{ query, hits }`. A backend without the search plugin installed
|
|
1497
|
-
* answers `404`; we treat that as "no global search here" and return an empty
|
|
1498
|
-
* hit set so callers can fall back to a per-object fanout rather than surface
|
|
1499
|
-
* an error.
|
|
1500
|
-
*/
|
|
1501
|
-
async searchAll(
|
|
1502
|
-
query: string,
|
|
1503
|
-
options?: { limit?: number; objects?: string[] },
|
|
1504
|
-
): Promise<GlobalSearchResult> {
|
|
1505
|
-
const trimmed = (query ?? '').trim();
|
|
1506
|
-
if (trimmed === '') return { query: '', hits: [] };
|
|
1507
|
-
|
|
1508
|
-
await this.connect();
|
|
1509
|
-
|
|
1510
|
-
const queryParams = new URLSearchParams();
|
|
1511
|
-
queryParams.set('q', trimmed);
|
|
1512
|
-
if (options?.limit != null && options.limit > 0) {
|
|
1513
|
-
queryParams.set('limit', String(options.limit));
|
|
1514
|
-
}
|
|
1515
|
-
if (options?.objects && options.objects.length > 0) {
|
|
1516
|
-
queryParams.set('objects', options.objects.join(','));
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
const baseUrl = (this.baseUrl || '').replace(/\/$/, '');
|
|
1520
|
-
// Avoid doubling /api/v1 when baseUrl already carries the version suffix.
|
|
1521
|
-
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
1522
|
-
const searchPath = hasApiVersionSuffix ? '/search' : '/api/v1/search';
|
|
1523
|
-
const url = `${baseUrl}${searchPath}?${queryParams.toString()}`;
|
|
1524
|
-
|
|
1525
|
-
const headers: Record<string, string> = {
|
|
1526
|
-
'Content-Type': 'application/json',
|
|
1527
|
-
};
|
|
1528
|
-
if (this.token) {
|
|
1529
|
-
headers['Authorization'] = `Bearer ${this.token}`;
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
const res = await this.fetchImpl(url, { method: 'GET', headers });
|
|
1533
|
-
|
|
1534
|
-
if (!res.ok) {
|
|
1535
|
-
// 404 → the search plugin isn't installed on this backend. Degrade to an
|
|
1536
|
-
// empty result so the caller can fall back instead of hard-failing.
|
|
1537
|
-
if (res.status === 404) return { query: trimmed, hits: [] };
|
|
1538
|
-
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
|
|
1539
|
-
const err = new Error(
|
|
1540
|
-
errorBody?.error?.message || errorBody?.message || res.statusText,
|
|
1541
|
-
) as Error & { status?: number };
|
|
1542
|
-
err.status = res.status;
|
|
1543
|
-
throw err;
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
|
-
const body = await res.json();
|
|
1547
|
-
// Unwrap the standard `{ success, data }` envelope when present; the search
|
|
1548
|
-
// endpoint itself returns `{ query, hits }`.
|
|
1549
|
-
const payload =
|
|
1550
|
-
body && typeof body === 'object' && typeof body.success === 'boolean' && 'data' in body
|
|
1551
|
-
? body.data
|
|
1552
|
-
: body;
|
|
1553
|
-
|
|
1554
|
-
const rawHits: unknown[] = Array.isArray(payload?.hits)
|
|
1555
|
-
? payload.hits
|
|
1556
|
-
: Array.isArray(payload)
|
|
1557
|
-
? payload
|
|
1558
|
-
: [];
|
|
1559
|
-
|
|
1560
|
-
const hits: GlobalSearchHit[] = [];
|
|
1561
|
-
for (const entry of rawHits) {
|
|
1562
|
-
if (!entry || typeof entry !== 'object') continue;
|
|
1563
|
-
const h = entry as Record<string, any>;
|
|
1564
|
-
const object = h.object ?? h.objectName ?? h.object_name;
|
|
1565
|
-
const id = h.id ?? h.record?.id ?? h.record?._id;
|
|
1566
|
-
if (typeof object !== 'string' || id == null) continue;
|
|
1567
|
-
hits.push({
|
|
1568
|
-
object,
|
|
1569
|
-
id: String(id),
|
|
1570
|
-
title: typeof h.title === 'string' ? h.title : undefined,
|
|
1571
|
-
snippet: typeof h.snippet === 'string' ? h.snippet : undefined,
|
|
1572
|
-
record: h.record && typeof h.record === 'object' ? h.record : undefined,
|
|
1573
|
-
});
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
return {
|
|
1577
|
-
query: typeof payload?.query === 'string' ? payload.query : trimmed,
|
|
1578
|
-
hits,
|
|
1579
|
-
};
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
/**
|
|
1583
|
-
* Find a single record by ID.
|
|
1584
|
-
*/
|
|
1585
|
-
async findOne(resource: string, id: string | number, params?: QueryParams): Promise<T | null> {
|
|
1586
|
-
await this.connect();
|
|
1587
|
-
|
|
1588
|
-
// When $expand is requested, use a raw GET request with a filter by id
|
|
1589
|
-
// and populate. The installed server v3.0.10's getData() does not support
|
|
1590
|
-
// expand/populate, so we route through findData which does.
|
|
1591
|
-
if (params?.$expand && params.$expand.length > 0) {
|
|
1592
|
-
try {
|
|
1593
|
-
const findParams: QueryParams = {
|
|
1594
|
-
...params,
|
|
1595
|
-
$filter: { id: String(id) },
|
|
1596
|
-
$top: 1,
|
|
1597
|
-
};
|
|
1598
|
-
const result = await this.rawFindWithPopulate(resource, findParams);
|
|
1599
|
-
// Handle array responses (some servers return data as flat arrays)
|
|
1600
|
-
if (Array.isArray(result)) {
|
|
1601
|
-
return result[0] || null;
|
|
1602
|
-
}
|
|
1603
|
-
const resultObj = result as { records?: T[]; value?: T[] };
|
|
1604
|
-
const records = resultObj.records || resultObj.value || [];
|
|
1605
|
-
return records[0] || null;
|
|
1606
|
-
} catch (error: unknown) {
|
|
1607
|
-
if (is404Error(error)) {
|
|
1608
|
-
return null;
|
|
1609
|
-
}
|
|
1610
|
-
// Fall through to direct GET without $expand — some servers don't
|
|
1611
|
-
// support the filter+populate API, so gracefully degrade to a
|
|
1612
|
-
// simple data.get() call below rather than failing with "Record not found".
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
try {
|
|
1617
|
-
const result = await this.client.data.get<T>(resource, String(id));
|
|
1618
|
-
return result.record;
|
|
1619
|
-
} catch (error: unknown) {
|
|
1620
|
-
// If record not found, return null instead of throwing
|
|
1621
|
-
if (is404Error(error)) {
|
|
1622
|
-
return null;
|
|
1623
|
-
}
|
|
1624
|
-
throw error;
|
|
1625
|
-
}
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
|
-
/**
|
|
1629
|
-
* Create a new record.
|
|
1630
|
-
*/
|
|
1631
|
-
/**
|
|
1632
|
-
* Notify all mutation subscribers. A throwing listener must not break the
|
|
1633
|
-
* mutation or starve the other subscribers, so each is isolated.
|
|
1634
|
-
*/
|
|
1635
|
-
private emitMutation(event: DataSourceMutationEvent<T>): void {
|
|
1636
|
-
for (const listener of this.mutationListeners) {
|
|
1637
|
-
try {
|
|
1638
|
-
listener(event);
|
|
1639
|
-
} catch (err) {
|
|
1640
|
-
console.warn('ObjectStackAdapter: mutation listener error', err);
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
|
|
1645
|
-
/**
|
|
1646
|
-
* Subscribe to create/update/delete events on any resource. Returns an
|
|
1647
|
-
* unsubscribe function. Data-bound views use this to auto-refresh after a
|
|
1648
|
-
* mutation (e.g. inline-edit "Save All", which writes through `update` and
|
|
1649
|
-
* must repaint the list without a manual reload).
|
|
1650
|
-
*/
|
|
1651
|
-
onMutation(callback: (event: DataSourceMutationEvent<T>) => void): () => void {
|
|
1652
|
-
this.mutationListeners.add(callback);
|
|
1653
|
-
return () => {
|
|
1654
|
-
this.mutationListeners.delete(callback);
|
|
1655
|
-
};
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
/**
|
|
1659
|
-
* Notify all write-warning subscribers. Isolated like {@link emitMutation}: a
|
|
1660
|
-
* throwing listener must not break the write or starve the others.
|
|
1661
|
-
*/
|
|
1662
|
-
private emitWriteWarning(event: WriteWarningEvent): void {
|
|
1663
|
-
for (const listener of this.writeWarningListeners) {
|
|
1664
|
-
try {
|
|
1665
|
-
listener(event);
|
|
1666
|
-
} catch (err) {
|
|
1667
|
-
console.warn('ObjectStackAdapter: write-warning listener error', err);
|
|
1668
|
-
}
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1671
|
-
|
|
1672
|
-
/**
|
|
1673
|
-
* Read `droppedFields` off a create/update response (framework #3431/#3455)
|
|
1674
|
-
* and, when present, notify write-warning subscribers. Tolerant of a client
|
|
1675
|
-
* whose response type predates `droppedFields`: the field is read structurally
|
|
1676
|
-
* and validated, so an older client (or a backend that never drops) is a no-op.
|
|
1677
|
-
*/
|
|
1678
|
-
private notifyDroppedFields(
|
|
1679
|
-
operation: 'create' | 'update',
|
|
1680
|
-
resource: string,
|
|
1681
|
-
result: unknown,
|
|
1682
|
-
id?: string | number,
|
|
1683
|
-
sent?: Record<string, unknown> | null,
|
|
1684
|
-
): void {
|
|
1685
|
-
const dropped = (result as { droppedFields?: unknown } | null | undefined)?.droppedFields;
|
|
1686
|
-
if (!Array.isArray(dropped) || dropped.length === 0) return;
|
|
1687
|
-
const valid = dropped.filter(
|
|
1688
|
-
(e): e is DroppedFieldsEvent =>
|
|
1689
|
-
!!e && typeof e === 'object' && Array.isArray((e as DroppedFieldsEvent).fields) && (e as DroppedFieldsEvent).fields.length > 0,
|
|
1690
|
-
);
|
|
1691
|
-
// A strip that changed nothing is not news — see withoutNoOpDrops (#3484).
|
|
1692
|
-
const stored = (result as { record?: Record<string, unknown> } | null | undefined)?.record;
|
|
1693
|
-
const droppedFields = withoutNoOpDrops(valid, sent, stored);
|
|
1694
|
-
if (droppedFields.length === 0) return;
|
|
1695
|
-
this.emitWriteWarning({ operation, resource, ...(id !== undefined ? { id } : {}), droppedFields });
|
|
1696
|
-
}
|
|
1697
|
-
|
|
1698
|
-
/**
|
|
1699
|
-
* Same, for the cross-object transactional batch (framework #3794). Its
|
|
1700
|
-
* response hangs the events off a top-level `droppedFields` list, each tagged
|
|
1701
|
-
* with the `index` of the operation it came from — `results` entries are bare
|
|
1702
|
-
* record echoes with nowhere to hang a per-row list.
|
|
1703
|
-
*
|
|
1704
|
-
* This is the path that matters most for the warning: `batchTransaction` is
|
|
1705
|
-
* how the console's record form saves a master-detail record, so a
|
|
1706
|
-
* `readonlyWhen`-locked field edited in that form was stripped server-side
|
|
1707
|
-
* while the UI reported a plain success. The operation kind is taken from the
|
|
1708
|
-
* originating op so the toast doesn't call an update a create.
|
|
1709
|
-
*/
|
|
1710
|
-
private notifyBatchDroppedFields(
|
|
1711
|
-
operations: BatchTransactionOperation[],
|
|
1712
|
-
payload: unknown,
|
|
1713
|
-
): void {
|
|
1714
|
-
const dropped = (payload as { droppedFields?: unknown } | null | undefined)?.droppedFields;
|
|
1715
|
-
if (!Array.isArray(dropped) || dropped.length === 0) return;
|
|
1716
|
-
const results = (payload as { results?: unknown[] } | null | undefined)?.results;
|
|
1717
|
-
for (const entry of dropped) {
|
|
1718
|
-
if (!entry || typeof entry !== 'object') continue;
|
|
1719
|
-
const e = entry as DroppedFieldsEvent & { index?: number };
|
|
1720
|
-
if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
|
|
1721
|
-
const op = typeof e.index === 'number' ? operations[e.index] : undefined;
|
|
1722
|
-
// Same no-op suppression as the single-record path (#3484). The echoed
|
|
1723
|
-
// row for the originating op is the "stored" side; when the batch echoed
|
|
1724
|
-
// nothing usable, `withoutNoOpDrops` keeps every field.
|
|
1725
|
-
const stored =
|
|
1726
|
-
typeof e.index === 'number' && Array.isArray(results)
|
|
1727
|
-
? (results[e.index] as Record<string, unknown> | undefined)
|
|
1728
|
-
: undefined;
|
|
1729
|
-
const [live] = withoutNoOpDrops(
|
|
1730
|
-
[{ object: e.object, fields: e.fields, reason: e.reason }],
|
|
1731
|
-
op?.data as Record<string, unknown> | undefined,
|
|
1732
|
-
stored,
|
|
1733
|
-
);
|
|
1734
|
-
if (!live) continue;
|
|
1735
|
-
// `delete` never drops fields; anything unexpected reads as an update,
|
|
1736
|
-
// which is the truthful default for a batch that echoed a strip.
|
|
1737
|
-
const operation: 'create' | 'update' = (op?.action ?? 'create') === 'create' ? 'create' : 'update';
|
|
1738
|
-
this.emitWriteWarning({
|
|
1739
|
-
operation,
|
|
1740
|
-
resource: e.object ?? op?.object ?? '',
|
|
1741
|
-
...(op?.id !== undefined && op?.id !== null ? { id: op.id } : {}),
|
|
1742
|
-
droppedFields: [live],
|
|
1743
|
-
});
|
|
1744
|
-
}
|
|
1745
|
-
}
|
|
1746
|
-
|
|
1747
|
-
/**
|
|
1748
|
-
* Subscribe to write-warning events (a create/update dropped caller-supplied
|
|
1749
|
-
* fields — #3431/#3455). Returns an unsubscribe function. The app shell uses
|
|
1750
|
-
* this to toast the user; the write itself already succeeded.
|
|
1751
|
-
*/
|
|
1752
|
-
onWriteWarning(callback: WriteWarningListener): () => void {
|
|
1753
|
-
this.writeWarningListeners.add(callback);
|
|
1754
|
-
return () => {
|
|
1755
|
-
this.writeWarningListeners.delete(callback);
|
|
1756
|
-
};
|
|
1757
|
-
}
|
|
1758
|
-
|
|
1759
|
-
/**
|
|
1760
|
-
* Subscribe to metadata save-advisory events — the runtime authoring gate's
|
|
1761
|
-
* advisory findings on a save that SUCCEEDED (#4237; backend
|
|
1762
|
-
* objectstack#7435). Returns an unsubscribe function.
|
|
1763
|
-
*
|
|
1764
|
-
* Deliberately the same seam as {@link onWriteWarning} (#3431/#3455), which
|
|
1765
|
-
* is what {@link MetadataSaveAdvisoryEvent}'s own declaration already said it
|
|
1766
|
-
* was modelled on. It is a SIBLING of that channel rather than a second
|
|
1767
|
-
* payload pushed down it: `WriteWarningEvent` is a closed shape whose
|
|
1768
|
-
* `droppedFields` is required and means "fields the write legally stripped",
|
|
1769
|
-
* so carrying advisories on it would either force every existing
|
|
1770
|
-
* `onWriteWarning` consumer to grow a branch or make the event lie about what
|
|
1771
|
-
* happened. The seam's SHAPE is what is reused here — a long-lived instance
|
|
1772
|
-
* with a `subscribe → unsubscribe` registration that `AdapterProvider` wires
|
|
1773
|
-
* once — not its event type.
|
|
1774
|
-
*
|
|
1775
|
-
* Why here and not on the config, which is how the other client class does it
|
|
1776
|
-
* (#4133/#4236): `MetadataClient` is minted per component by
|
|
1777
|
-
* `useMetadataClient`, so it has no instance to subscribe to and its sink
|
|
1778
|
-
* rides the factory. `ObjectStackAdapter` is the opposite — one long-lived
|
|
1779
|
-
* instance per app, already carrying this exact subscription pattern.
|
|
1780
|
-
*/
|
|
1781
|
-
onSaveAdvisory(callback: MetadataSaveAdvisoryListener): () => void {
|
|
1782
|
-
this.saveAdvisoryListeners.add(callback);
|
|
1783
|
-
return () => {
|
|
1784
|
-
this.saveAdvisoryListeners.delete(callback);
|
|
1785
|
-
};
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
/**
|
|
1789
|
-
* Notify all save-advisory subscribers. Isolated exactly like
|
|
1790
|
-
* {@link emitWriteWarning}: a throwing listener must neither break the save
|
|
1791
|
-
* nor starve the others.
|
|
1792
|
-
*/
|
|
1793
|
-
private emitSaveAdvisory(event: MetadataSaveAdvisoryEvent): void {
|
|
1794
|
-
for (const listener of this.saveAdvisoryListeners) {
|
|
1795
|
-
try {
|
|
1796
|
-
listener(event);
|
|
1797
|
-
} catch (err) {
|
|
1798
|
-
console.warn('ObjectStackAdapter: save-advisory listener error', err);
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
}
|
|
1802
|
-
|
|
1803
|
-
/**
|
|
1804
|
-
* Install the ONE emitter for the metadata save door (#4237).
|
|
1805
|
-
*
|
|
1806
|
-
* ## Why this seam, and what it covers
|
|
1807
|
-
*
|
|
1808
|
-
* `ObjectStackClient.meta.saveItem` is the second client class that writes
|
|
1809
|
-
* through `PUT /api/v1/meta/:type/:name`, and every one of its callers reaches
|
|
1810
|
-
* it through an adapter this class constructed — the four inside this file
|
|
1811
|
-
* (`updateViewConfig`, the two view paths, `updateDashboard`) via
|
|
1812
|
-
* `this.client`, and every caller outside it via {@link getClient}, which
|
|
1813
|
-
* hands back this same instance: `MetadataService` (app-shell, five saves),
|
|
1814
|
-
* `useNavigationSync`, and plugin-designer's Create/EditAppPage. Wrapping the
|
|
1815
|
-
* method once here therefore covers all of them WITHOUT a per-site edit, which
|
|
1816
|
-
* is the whole point — a toast copied into a dozen call sites is the shape
|
|
1817
|
-
* #4133 rejected for the other client class and it is no better here.
|
|
1818
|
-
*
|
|
1819
|
-
* `meta` is an own, writable property assigned per instance in the SDK's
|
|
1820
|
-
* constructor (`this.meta = { … }`), and the client this adapter builds is
|
|
1821
|
-
* never shared, so the wrap is bounded to an object this adapter owns for its
|
|
1822
|
-
* whole lifetime. It is not a prototype or global patch.
|
|
1823
|
-
*
|
|
1824
|
-
* ## Response shape — measured, not assumed
|
|
1825
|
-
*
|
|
1826
|
-
* The two client classes' envelopes coincide at the top level, which is what
|
|
1827
|
-
* makes `readSaveAdvisories` reusable unchanged across both. `SaveMetaItem-
|
|
1828
|
-
* ResponseSchema` puts `advisories` at the body's top level next to
|
|
1829
|
-
* `success` / `version` / `seq` / `state`, and the SDK's `unwrapResponse`
|
|
1830
|
-
* strips its `{ success, data }` envelope only when the body actually HAS a
|
|
1831
|
-
* `data` key — this body does not, so it is returned verbatim. So the same
|
|
1832
|
-
* reader that `MetadataClient.save` uses reads this response correctly, and
|
|
1833
|
-
* the pins in `onSaveAdvisory.test.ts` drive a real SDK client through a fake
|
|
1834
|
-
* `fetch` rather than stubbing `meta`, so that continues to be measured.
|
|
1835
|
-
*
|
|
1836
|
-
* ## Draft-door honesty (D1)
|
|
1837
|
-
*
|
|
1838
|
-
* Drafts are NEVER gated: the framework returns at its D1 early-return
|
|
1839
|
-
* (`if (args.state !== 'active') return null`) before running a rule, so a
|
|
1840
|
-
* draft save produces no findings to withhold. This client class has no draft
|
|
1841
|
-
* door at all to worry about — the SDK's `saveItem(type, name, item)` takes no
|
|
1842
|
-
* mode and always writes the active door, which is exactly why the gate DOES
|
|
1843
|
-
* run for its callers. `mode` on the emitted event is therefore derived from
|
|
1844
|
-
* the response's own `state` rather than from a request-side flag that does
|
|
1845
|
-
* not exist here: `'draft'` when the server says the row landed as a draft,
|
|
1846
|
-
* `'publish'` otherwise. That keeps the event truthful about which door it
|
|
1847
|
-
* came through instead of hard-coding one.
|
|
1848
|
-
*/
|
|
1849
|
-
private installSaveAdvisoryInterceptor(): void {
|
|
1850
|
-
const meta = this.client.meta;
|
|
1851
|
-
const original = meta.saveItem.bind(meta);
|
|
1852
|
-
meta.saveItem = async (type: string, name: string, item: any) => {
|
|
1853
|
-
const result = await original(type, name, item);
|
|
1854
|
-
// Everything below is best-effort by construction: the row is already
|
|
1855
|
-
// committed server-side, so nothing the advisory channel does may change
|
|
1856
|
-
// what this call returns or whether it throws.
|
|
1857
|
-
try {
|
|
1858
|
-
const advisories = readSaveAdvisories(result);
|
|
1859
|
-
if (advisories.length > 0) {
|
|
1860
|
-
this.emitSaveAdvisory({
|
|
1861
|
-
type,
|
|
1862
|
-
name,
|
|
1863
|
-
mode: (result as { state?: string } | null | undefined)?.state === 'draft' ? 'draft' : 'publish',
|
|
1864
|
-
advisories,
|
|
1865
|
-
});
|
|
1866
|
-
}
|
|
1867
|
-
} catch (err) {
|
|
1868
|
-
/* an advisory must never turn a committed save into a thrown error */
|
|
1869
|
-
console.warn('ObjectStackAdapter: save-advisory read error', err);
|
|
1870
|
-
}
|
|
1871
|
-
return result;
|
|
1872
|
-
};
|
|
1873
|
-
}
|
|
1874
|
-
|
|
1875
|
-
async create(resource: string, data: Partial<T>): Promise<T> {
|
|
1876
|
-
await this.connect();
|
|
1877
|
-
try {
|
|
1878
|
-
const result = await this.client.data.create<T>(resource, data);
|
|
1879
|
-
this.emitMutation({ type: 'create', resource, record: { ...result.record } });
|
|
1880
|
-
this.notifyDroppedFields(
|
|
1881
|
-
'create',
|
|
1882
|
-
resource,
|
|
1883
|
-
result,
|
|
1884
|
-
(result.record as { id?: string | number } | undefined)?.id,
|
|
1885
|
-
data as Record<string, unknown>,
|
|
1886
|
-
);
|
|
1887
|
-
return result.record;
|
|
1888
|
-
} catch (err) {
|
|
1889
|
-
// `update` has always normalised; `create` did not, so a rejected insert
|
|
1890
|
-
// reached callers as the raw client error — no typed shape to branch on,
|
|
1891
|
-
// and its `fields[]` unreachable. A create is the path that most often
|
|
1892
|
-
// trips required-field validation, so it needs this more, not less.
|
|
1893
|
-
throw normaliseClientError(err);
|
|
1894
|
-
}
|
|
1895
|
-
}
|
|
1896
|
-
|
|
1897
|
-
/**
|
|
1898
|
-
* Update an existing record.
|
|
1899
|
-
*
|
|
1900
|
-
* Optional `opts.ifMatch` enables Optimistic Concurrency Control: the
|
|
1901
|
-
* server compares the supplied token (typically the `updated_at` value
|
|
1902
|
-
* the caller previously read) against the record's current version
|
|
1903
|
-
* and throws a {@link ConcurrentUpdateError} on mismatch (HTTP 409).
|
|
1904
|
-
*
|
|
1905
|
-
* Requires `@objectstack/client@>=4.2.0`, which forwards `opts.ifMatch`
|
|
1906
|
-
* as an `If-Match` HTTP header.
|
|
1907
|
-
*/
|
|
1908
|
-
async update(
|
|
1909
|
-
resource: string,
|
|
1910
|
-
id: string | number,
|
|
1911
|
-
data: Partial<T>,
|
|
1912
|
-
opts?: { ifMatch?: string },
|
|
1913
|
-
): Promise<T> {
|
|
1914
|
-
await this.connect();
|
|
1915
|
-
try {
|
|
1916
|
-
const result = await this.client.data.update<T>(
|
|
1917
|
-
resource,
|
|
1918
|
-
String(id),
|
|
1919
|
-
data,
|
|
1920
|
-
opts?.ifMatch ? { ifMatch: opts.ifMatch } : undefined,
|
|
1921
|
-
);
|
|
1922
|
-
this.emitMutation({ type: 'update', resource, id, record: { ...result.record } });
|
|
1923
|
-
this.notifyDroppedFields('update', resource, result, id, data as Record<string, unknown>);
|
|
1924
|
-
return result.record;
|
|
1925
|
-
} catch (err) {
|
|
1926
|
-
throw normaliseClientError(err);
|
|
1927
|
-
}
|
|
1928
|
-
}
|
|
1929
|
-
|
|
1930
|
-
/**
|
|
1931
|
-
* Delete a record.
|
|
1932
|
-
*
|
|
1933
|
-
* Optional `opts.ifMatch` enables Optimistic Concurrency Control —
|
|
1934
|
-
* see {@link update} for details. On 409 the call rejects with
|
|
1935
|
-
* a {@link ConcurrentUpdateError}.
|
|
1936
|
-
*/
|
|
1937
|
-
async delete(
|
|
1938
|
-
resource: string,
|
|
1939
|
-
id: string | number,
|
|
1940
|
-
opts?: { ifMatch?: string },
|
|
1941
|
-
): Promise<boolean> {
|
|
1942
|
-
await this.connect();
|
|
1943
|
-
try {
|
|
1944
|
-
const result = await this.client.data.delete(
|
|
1945
|
-
resource,
|
|
1946
|
-
String(id),
|
|
1947
|
-
opts?.ifMatch ? { ifMatch: opts.ifMatch } : undefined,
|
|
1948
|
-
);
|
|
1949
|
-
// `success`, not `deleted` (objectstack#5638). `DeleteDataResult.deleted`
|
|
1950
|
-
// was a key no schema ever declared and no server path ever returned on
|
|
1951
|
-
// `DELETE /data/:object/:id` — the client's interface was a wrong CLAIM
|
|
1952
|
-
// about the response body, and `@objectstack/client` 17.0.0-rc.5
|
|
1953
|
-
// corrected it to the schema's `success`.
|
|
1954
|
-
//
|
|
1955
|
-
// This was live here, not cosmetic: `result.deleted` compiled and read
|
|
1956
|
-
// `undefined` at runtime, so the guard below never fired — a successful
|
|
1957
|
-
// delete emitted NO mutation event, leaving every subscriber's cache
|
|
1958
|
-
// stale — and this method, declared `Promise<boolean>`, actually resolved
|
|
1959
|
-
// `undefined`. Following the rename is what restores both.
|
|
1960
|
-
if (result.success) {
|
|
1961
|
-
this.emitMutation({ type: 'delete', resource, id });
|
|
1962
|
-
}
|
|
1963
|
-
return result.success;
|
|
1964
|
-
} catch (err) {
|
|
1965
|
-
throw normaliseClientError(err);
|
|
1966
|
-
}
|
|
1967
|
-
}
|
|
1968
|
-
|
|
1969
|
-
/**
|
|
1970
|
-
* Apply the same patch to many records in a single round-trip.
|
|
1971
|
-
*
|
|
1972
|
-
* Sends one `POST /api/v1/data/:object/updateMany` request whose body
|
|
1973
|
-
* is `{ records: ids.map(id => ({id, data: patch})), options: { continueOnError: true }}`.
|
|
1974
|
-
* The server iterates server-side (still N engine writes) but the
|
|
1975
|
-
* client only pays for ONE HTTP/auth/RLS round-trip — the relevant
|
|
1976
|
-
* perf win for inbox / list-toolbar "mark all read" / "archive
|
|
1977
|
-
* selected" interactions where N can easily be in the hundreds.
|
|
1978
|
-
*
|
|
1979
|
-
* Falls back to a sequential per-id loop when the connected client
|
|
1980
|
-
* does not expose `updateMany` (older clients / offline adapters).
|
|
1981
|
-
* In that case `continueOnError` semantics are emulated locally so
|
|
1982
|
-
* callers see the same return shape.
|
|
1983
|
-
*/
|
|
1984
|
-
async bulkUpdate(
|
|
1985
|
-
resource: string,
|
|
1986
|
-
ids: ReadonlyArray<string | number>,
|
|
1987
|
-
patch: Partial<T>,
|
|
1988
|
-
): Promise<number> {
|
|
1989
|
-
await this.connect();
|
|
1990
|
-
if (!ids || ids.length === 0) return 0;
|
|
1991
|
-
const records = ids.map((id) => ({ id: String(id), data: patch as any }));
|
|
1992
|
-
|
|
1993
|
-
// Notify subscribers once for the whole batch (not per-id) so a single
|
|
1994
|
-
// "mark all read"/"archive selected" refreshes bound views exactly once.
|
|
1995
|
-
const emitBulk = (count: number): number => {
|
|
1996
|
-
if (count > 0) this.emitMutation({ type: 'update', resource });
|
|
1997
|
-
return count;
|
|
1998
|
-
};
|
|
1999
|
-
|
|
2000
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2001
|
-
const updateMany = (this.client.data as any).updateMany;
|
|
2002
|
-
if (typeof updateMany === 'function') {
|
|
2003
|
-
try {
|
|
2004
|
-
const res = await updateMany(resource, records, { continueOnError: true });
|
|
2005
|
-
// The server returns BatchUpdateResponse { succeeded, failed, ... };
|
|
2006
|
-
// fall back to ids.length on adapters that return a bare array.
|
|
2007
|
-
if (res && typeof res === 'object' && typeof (res as any).succeeded === 'number') {
|
|
2008
|
-
return emitBulk((res as any).succeeded as number);
|
|
2009
|
-
}
|
|
2010
|
-
if (Array.isArray(res)) return emitBulk((res as any[]).length);
|
|
2011
|
-
return emitBulk(ids.length);
|
|
2012
|
-
} catch (err) {
|
|
2013
|
-
throw normaliseClientError(err);
|
|
2014
|
-
}
|
|
2015
|
-
}
|
|
2016
|
-
|
|
2017
|
-
// Fallback: sequential per-id updates, tolerating failures.
|
|
2018
|
-
let succeeded = 0;
|
|
2019
|
-
for (const id of ids) {
|
|
2020
|
-
try {
|
|
2021
|
-
await this.client.data.update<T>(resource, String(id), patch);
|
|
2022
|
-
succeeded++;
|
|
2023
|
-
} catch {
|
|
2024
|
-
// continueOnError semantics — swallow per-row errors
|
|
2025
|
-
}
|
|
2026
|
-
}
|
|
2027
|
-
return emitBulk(succeeded);
|
|
2028
|
-
}
|
|
2029
|
-
|
|
2030
|
-
/**
|
|
2031
|
-
* Single-call bulk delete. Mirrors the bulkUpdate contract: prefers
|
|
2032
|
-
* the server's `deleteMany` primitive when the client supports it;
|
|
2033
|
-
* otherwise emulates `continueOnError` by looping `delete` per id and
|
|
2034
|
-
* swallowing per-row failures. Returns the count of rows reported
|
|
2035
|
-
* deleted by the server (or successfully deleted in fallback mode).
|
|
2036
|
-
*/
|
|
2037
|
-
async bulkDelete(
|
|
2038
|
-
resource: string,
|
|
2039
|
-
ids: ReadonlyArray<string | number>,
|
|
2040
|
-
): Promise<number> {
|
|
2041
|
-
await this.connect();
|
|
2042
|
-
if (!ids || ids.length === 0) return 0;
|
|
2043
|
-
const strIds = ids.map((id) => String(id));
|
|
2044
|
-
|
|
2045
|
-
// Notify subscribers once for the whole batch (see bulkUpdate).
|
|
2046
|
-
const emitBulk = (count: number): number => {
|
|
2047
|
-
if (count > 0) this.emitMutation({ type: 'delete', resource });
|
|
2048
|
-
return count;
|
|
2049
|
-
};
|
|
2050
|
-
|
|
2051
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2052
|
-
const deleteMany = (this.client.data as any).deleteMany;
|
|
2053
|
-
if (typeof deleteMany === 'function') {
|
|
2054
|
-
try {
|
|
2055
|
-
const res = await deleteMany(resource, strIds, { continueOnError: true });
|
|
2056
|
-
if (res && typeof res === 'object' && typeof (res as any).succeeded === 'number') {
|
|
2057
|
-
return emitBulk((res as any).succeeded as number);
|
|
2058
|
-
}
|
|
2059
|
-
if (Array.isArray(res)) return emitBulk((res as any[]).length);
|
|
2060
|
-
// deleteMany historically returns void on success — assume all hit.
|
|
2061
|
-
return emitBulk(strIds.length);
|
|
2062
|
-
} catch (err) {
|
|
2063
|
-
throw normaliseClientError(err);
|
|
2064
|
-
}
|
|
2065
|
-
}
|
|
2066
|
-
|
|
2067
|
-
// Fallback: sequential per-id deletes, tolerating failures.
|
|
2068
|
-
let succeeded = 0;
|
|
2069
|
-
for (const id of strIds) {
|
|
2070
|
-
try {
|
|
2071
|
-
await this.client.data.delete(resource, id);
|
|
2072
|
-
succeeded++;
|
|
2073
|
-
} catch {
|
|
2074
|
-
// continueOnError semantics — swallow per-row errors
|
|
2075
|
-
}
|
|
2076
|
-
}
|
|
2077
|
-
return emitBulk(succeeded);
|
|
2078
|
-
}
|
|
2079
|
-
|
|
2080
|
-
/**
|
|
2081
|
-
* Bulk operations with optimized batch processing and error handling.
|
|
2082
|
-
* Emits progress events for tracking operation status.
|
|
2083
|
-
*
|
|
2084
|
-
* @param resource - Resource name
|
|
2085
|
-
* @param operation - Operation type (create, update, delete)
|
|
2086
|
-
* @param data - Array of records to process
|
|
2087
|
-
* @returns Promise resolving to array of results
|
|
2088
|
-
*/
|
|
2089
|
-
/**
|
|
2090
|
-
* Cross-object transactional batch (ObjectStack #1604 / ADR-0034 item 4).
|
|
2091
|
-
* Runs the operations in ONE server transaction — commit all or roll back
|
|
2092
|
-
* all. A field value of `{ $ref: <earlier op index> }` resolves to that op's
|
|
2093
|
-
* created id, so a child can reference its parent created earlier in the same
|
|
2094
|
-
* batch (master-detail).
|
|
2095
|
-
*
|
|
2096
|
-
* Transport: the published `@objectstack/client` SDK method
|
|
2097
|
-
* `data.batchTransaction` (framework #3271; shipped since client v16, our
|
|
2098
|
-
* dependency floor). Per AGENTS.md §7 data always flows through the client —
|
|
2099
|
-
* never a hand-rolled `fetch('/api/v1/batch')`.
|
|
2100
|
-
*
|
|
2101
|
-
* Fallback decision — declarative capability negotiation (framework #3298 /
|
|
2102
|
-
* objectui #2693). At connect() we read `capabilities.transactionalBatch`
|
|
2103
|
-
* from discovery:
|
|
2104
|
-
* - Declared `true` → the backend GUARANTEES atomicity (declared ===
|
|
2105
|
-
* enforced). We TRUST it: any batch failure — including 404/405/501 —
|
|
2106
|
-
* surfaces as a real error. No non-atomic client-side compensation. This
|
|
2107
|
-
* is the path modern backends take.
|
|
2108
|
-
* - Declared `false`, or ABSENT (backend predates #3298) → we can't rely on
|
|
2109
|
-
* server atomicity, so we keep the legacy behaviour: on 404/405 (no
|
|
2110
|
-
* endpoint) or 501 (runtime without transactions) degrade to the
|
|
2111
|
-
* client-side, NON-atomic {@link emulateBatchTransaction} so a save is
|
|
2112
|
-
* still possible. Removing that here would regress older backends from
|
|
2113
|
-
* "saves, less safe" to "no save path" (#2679 compatibility constraint).
|
|
2114
|
-
* The non-atomic fallback stays isolated to THIS adapter.
|
|
2115
|
-
*/
|
|
2116
|
-
async batchTransaction(
|
|
2117
|
-
operations: BatchTransactionOperation[],
|
|
2118
|
-
): Promise<{ results: any[] }> {
|
|
2119
|
-
// Ensure discovery (and thus the #3298 capability) is loaded so the
|
|
2120
|
-
// decision below is declarative, not "fire a batch and read the status".
|
|
2121
|
-
await this.connect();
|
|
2122
|
-
|
|
2123
|
-
// When the backend declares atomic batch support we never degrade: a
|
|
2124
|
-
// failure is a real error, not a cue to fall back. Otherwise (declared
|
|
2125
|
-
// false, or capability absent on a pre-#3298 backend) the emulation
|
|
2126
|
-
// fallback below stays active.
|
|
2127
|
-
const guaranteed = this.atomicBatchCapability === true;
|
|
2128
|
-
|
|
2129
|
-
// Already degraded on a non-declaring backend — skip the SDK call.
|
|
2130
|
-
// (Unreachable once `guaranteed`: that path never sets `batchUnsupported`.)
|
|
2131
|
-
if (!guaranteed && this.batchUnsupported) {
|
|
2132
|
-
return emulateBatchTransaction(this, operations);
|
|
2133
|
-
}
|
|
2134
|
-
|
|
2135
|
-
try {
|
|
2136
|
-
// Typed SDK method — guaranteed present by the `@objectstack/client@^16`
|
|
2137
|
-
// dependency floor (framework #3271). No hand-rolled POST /api/v1/batch.
|
|
2138
|
-
const payload = await this.client.data.batchTransaction(operations);
|
|
2139
|
-
this.emitBatchMutations(operations, payload?.results);
|
|
2140
|
-
this.notifyBatchDroppedFields(operations, payload);
|
|
2141
|
-
return payload;
|
|
2142
|
-
} catch (err) {
|
|
2143
|
-
// On a non-declaring backend, endpoint missing (404/405) or a runtime that
|
|
2144
|
-
// can't do transactions (the framework rest-server answers 501
|
|
2145
|
-
// "Transactional batch not supported by this runtime") → degrade to the
|
|
2146
|
-
// non-atomic client emulation so the save still goes through. When the
|
|
2147
|
-
// backend DECLARED support (`guaranteed`), even these are hard errors — a
|
|
2148
|
-
// server that advertised the capability must honour it. Every other status
|
|
2149
|
-
// (400 validation, 401/403 auth, 409 conflict, 500 fault) is a real error
|
|
2150
|
-
// the caller must see — never silently retried.
|
|
2151
|
-
const status = this.errorStatusOf(err);
|
|
2152
|
-
if (!guaranteed && this.batchStatusUnsupported(status)) {
|
|
2153
|
-
return this.fallbackToEmulation(operations, status);
|
|
2154
|
-
}
|
|
2155
|
-
throw err;
|
|
2156
|
-
}
|
|
2157
|
-
}
|
|
2158
|
-
|
|
2159
|
-
/** True for statuses that mean "this backend can't do a transactional batch". */
|
|
2160
|
-
private batchStatusUnsupported(status: number | undefined): boolean {
|
|
2161
|
-
return status === 404 || status === 405 || status === 501;
|
|
2162
|
-
}
|
|
2163
|
-
|
|
2164
|
-
/** Best-effort HTTP status extraction from a thrown SDK/client error. */
|
|
2165
|
-
private errorStatusOf(err: unknown): number | undefined {
|
|
2166
|
-
if (!err || typeof err !== 'object') return undefined;
|
|
2167
|
-
const e = err as Record<string, unknown>;
|
|
2168
|
-
const s = e.httpStatus ?? e.status ?? e.statusCode;
|
|
2169
|
-
return typeof s === 'number' ? s : undefined;
|
|
2170
|
-
}
|
|
2171
|
-
|
|
2172
|
-
/** Mark the endpoint unsupported (warn once) and serve via emulation. */
|
|
2173
|
-
private fallbackToEmulation(
|
|
2174
|
-
operations: BatchTransactionOperation[],
|
|
2175
|
-
status: number | undefined,
|
|
2176
|
-
): Promise<{ results: any[] }> {
|
|
2177
|
-
if (!this.batchUnsupported) {
|
|
2178
|
-
this.batchUnsupported = true;
|
|
2179
|
-
console.warn(
|
|
2180
|
-
`ObjectStackAdapter: POST /api/v1/batch unavailable (HTTP ${status ?? '?'}) — ` +
|
|
2181
|
-
'falling back to non-atomic client-side batch emulation. Cross-object ' +
|
|
2182
|
-
'saves on this backend are best-effort, not transactional.',
|
|
2183
|
-
);
|
|
2184
|
-
}
|
|
2185
|
-
return emulateBatchTransaction(this, operations);
|
|
2186
|
-
}
|
|
2187
|
-
|
|
2188
|
-
/**
|
|
2189
|
-
* Emit one DataSourceMutationEvent per committed operation so the invalidation bus
|
|
2190
|
-
* (#2269) sees writes that went through /batch exactly like single
|
|
2191
|
-
* create/update/delete calls — master-detail ModalForm saves otherwise leave
|
|
2192
|
-
* related lists and count badges stale (#2582). `results` is index-aligned
|
|
2193
|
-
* with `operations`; creates take id/record from the server echo.
|
|
2194
|
-
*
|
|
2195
|
-
* Only called on the server-committed paths. The emulation branch drives the
|
|
2196
|
-
* adapter's own create/update/delete primitives, which already emit — so it
|
|
2197
|
-
* must NOT be routed through here, or events would double-fire.
|
|
2198
|
-
*/
|
|
2199
|
-
private emitBatchMutations(
|
|
2200
|
-
operations: BatchTransactionOperation[],
|
|
2201
|
-
rawResults: unknown,
|
|
2202
|
-
): void {
|
|
2203
|
-
const results = Array.isArray(rawResults) ? rawResults : [];
|
|
2204
|
-
operations.forEach((op, i) => {
|
|
2205
|
-
const action = op.action ?? 'create';
|
|
2206
|
-
const echo = results[i];
|
|
2207
|
-
if (action === 'create') {
|
|
2208
|
-
this.emitMutation({ type: 'create', resource: op.object, record: echo });
|
|
2209
|
-
} else if (action === 'update') {
|
|
2210
|
-
this.emitMutation({ type: 'update', resource: op.object, id: op.id ?? echo?.id ?? echo?._id, record: echo });
|
|
2211
|
-
} else if (action === 'delete') {
|
|
2212
|
-
this.emitMutation({ type: 'delete', resource: op.object, id: op.id });
|
|
2213
|
-
}
|
|
2214
|
-
});
|
|
2215
|
-
}
|
|
2216
|
-
|
|
2217
|
-
async bulk(resource: string, operation: 'create' | 'update' | 'delete', data: Partial<T>[]): Promise<T[]> {
|
|
2218
|
-
await this.connect();
|
|
2219
|
-
|
|
2220
|
-
if (!data || data.length === 0) {
|
|
2221
|
-
return [];
|
|
2222
|
-
}
|
|
2223
|
-
|
|
2224
|
-
const total = data.length;
|
|
2225
|
-
let completed = 0;
|
|
2226
|
-
let failed = 0;
|
|
2227
|
-
|
|
2228
|
-
const emitProgress = () => {
|
|
2229
|
-
this.emitBatchProgress({
|
|
2230
|
-
operation,
|
|
2231
|
-
total,
|
|
2232
|
-
completed,
|
|
2233
|
-
failed,
|
|
2234
|
-
percentage: total > 0 ? (completed + failed) / total * 100 : 0,
|
|
2235
|
-
});
|
|
2236
|
-
};
|
|
2237
|
-
|
|
2238
|
-
try {
|
|
2239
|
-
switch (operation) {
|
|
2240
|
-
case 'create': {
|
|
2241
|
-
emitProgress();
|
|
2242
|
-
const created = await this.client.data.createMany<T>(resource, data);
|
|
2243
|
-
completed = created.length;
|
|
2244
|
-
failed = total - completed;
|
|
2245
|
-
emitProgress();
|
|
2246
|
-
// One resource-level event for the whole batch (same contract as
|
|
2247
|
-
// bulkUpdate/bulkDelete) so bound views refresh after subform child
|
|
2248
|
-
// rows are created through this path (#2582).
|
|
2249
|
-
if (completed > 0) this.emitMutation({ type: 'create', resource });
|
|
2250
|
-
return created;
|
|
2251
|
-
}
|
|
2252
|
-
|
|
2253
|
-
case 'delete': {
|
|
2254
|
-
const ids = data.map(item => (item as Record<string, unknown>).id).filter(Boolean) as string[];
|
|
2255
|
-
|
|
2256
|
-
if (ids.length === 0) {
|
|
2257
|
-
// Track which items are missing IDs
|
|
2258
|
-
const errors = data.map((_, index) => ({
|
|
2259
|
-
index,
|
|
2260
|
-
error: `Missing ID for item at index ${index}`
|
|
2261
|
-
}));
|
|
2262
|
-
|
|
2263
|
-
failed = data.length;
|
|
2264
|
-
emitProgress();
|
|
2265
|
-
|
|
2266
|
-
throw new BulkOperationError('delete', 0, data.length, errors);
|
|
2267
|
-
}
|
|
2268
|
-
|
|
2269
|
-
emitProgress();
|
|
2270
|
-
await this.client.data.deleteMany(resource, ids);
|
|
2271
|
-
completed = ids.length;
|
|
2272
|
-
failed = total - completed;
|
|
2273
|
-
emitProgress();
|
|
2274
|
-
// One resource-level event per batch — see the create branch.
|
|
2275
|
-
if (completed > 0) this.emitMutation({ type: 'delete', resource });
|
|
2276
|
-
return [] as T[];
|
|
2277
|
-
}
|
|
2278
|
-
|
|
2279
|
-
case 'update': {
|
|
2280
|
-
// Check if client supports updateMany
|
|
2281
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2282
|
-
if (typeof (this.client.data as any).updateMany === 'function') {
|
|
2283
|
-
try {
|
|
2284
|
-
emitProgress();
|
|
2285
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2286
|
-
const updateMany = (this.client.data as any).updateMany;
|
|
2287
|
-
const updated = await updateMany(resource, data) as T[];
|
|
2288
|
-
completed = updated.length;
|
|
2289
|
-
failed = total - completed;
|
|
2290
|
-
emitProgress();
|
|
2291
|
-
// One resource-level event per batch — see the create branch.
|
|
2292
|
-
if (completed > 0) this.emitMutation({ type: 'update', resource });
|
|
2293
|
-
return updated;
|
|
2294
|
-
} catch {
|
|
2295
|
-
// If updateMany is not supported, fall back to individual updates
|
|
2296
|
-
// Silently fallback without logging
|
|
2297
|
-
}
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
// Fallback: Process updates individually with detailed error tracking and progress
|
|
2301
|
-
const results: T[] = [];
|
|
2302
|
-
const errors: Array<{ index: number; error: unknown }> = [];
|
|
2303
|
-
|
|
2304
|
-
for (let i = 0; i < data.length; i++) {
|
|
2305
|
-
const item = data[i];
|
|
2306
|
-
const id = (item as Record<string, unknown>).id;
|
|
2307
|
-
|
|
2308
|
-
if (!id) {
|
|
2309
|
-
errors.push({ index: i, error: 'Missing ID' });
|
|
2310
|
-
failed++;
|
|
2311
|
-
emitProgress();
|
|
2312
|
-
continue;
|
|
2313
|
-
}
|
|
2314
|
-
|
|
2315
|
-
try {
|
|
2316
|
-
const result = await this.client.data.update<T>(resource, String(id), item);
|
|
2317
|
-
results.push(result.record);
|
|
2318
|
-
completed++;
|
|
2319
|
-
emitProgress();
|
|
2320
|
-
} catch (error: unknown) {
|
|
2321
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2322
|
-
errors.push({ index: i, error: errorMessage });
|
|
2323
|
-
failed++;
|
|
2324
|
-
emitProgress();
|
|
2325
|
-
}
|
|
2326
|
-
}
|
|
2327
|
-
|
|
2328
|
-
// Rows that DID persist must still reach subscribers, even when the
|
|
2329
|
-
// batch as a whole reports failure below (continueOnError semantics
|
|
2330
|
-
// — the successful writes are not rolled back).
|
|
2331
|
-
if (completed > 0) this.emitMutation({ type: 'update', resource });
|
|
2332
|
-
|
|
2333
|
-
// If there were any errors, throw BulkOperationError
|
|
2334
|
-
if (errors.length > 0) {
|
|
2335
|
-
throw new BulkOperationError(
|
|
2336
|
-
'update',
|
|
2337
|
-
results.length,
|
|
2338
|
-
errors.length,
|
|
2339
|
-
errors,
|
|
2340
|
-
{ resource, totalRecords: data.length }
|
|
2341
|
-
);
|
|
2342
|
-
}
|
|
2343
|
-
|
|
2344
|
-
return results;
|
|
2345
|
-
}
|
|
2346
|
-
|
|
2347
|
-
default:
|
|
2348
|
-
throw new ObjectStackError(
|
|
2349
|
-
`Unsupported bulk operation: ${operation}`,
|
|
2350
|
-
'UNSUPPORTED_OPERATION',
|
|
2351
|
-
400
|
|
2352
|
-
);
|
|
2353
|
-
}
|
|
2354
|
-
} catch (error: unknown) {
|
|
2355
|
-
// Emit final progress with failure
|
|
2356
|
-
emitProgress();
|
|
2357
|
-
|
|
2358
|
-
// If it's already a BulkOperationError, re-throw it
|
|
2359
|
-
if (error instanceof BulkOperationError) {
|
|
2360
|
-
throw error;
|
|
2361
|
-
}
|
|
2362
|
-
|
|
2363
|
-
// If it's already an ObjectStackError, re-throw it
|
|
2364
|
-
if (error instanceof ObjectStackError) {
|
|
2365
|
-
throw error;
|
|
2366
|
-
}
|
|
2367
|
-
|
|
2368
|
-
// Wrap other errors in BulkOperationError with proper error tracking
|
|
2369
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2370
|
-
const errors = data.map((_, index) => ({
|
|
2371
|
-
index,
|
|
2372
|
-
error: errorMessage
|
|
2373
|
-
}));
|
|
2374
|
-
|
|
2375
|
-
throw new BulkOperationError(
|
|
2376
|
-
operation,
|
|
2377
|
-
0,
|
|
2378
|
-
data.length,
|
|
2379
|
-
errors,
|
|
2380
|
-
{ resource, originalError: error }
|
|
2381
|
-
);
|
|
2382
|
-
}
|
|
2383
|
-
}
|
|
2384
|
-
|
|
2385
|
-
/**
|
|
2386
|
-
* Bulk-import raw spreadsheet rows in a single server round-trip via
|
|
2387
|
-
* `POST /api/v1/data/:object/import`. The server performs all value coercion
|
|
2388
|
-
* (booleans, numbers, dates→ISO, select label→code, lookup name→id) from the
|
|
2389
|
-
* object's field metadata, so this method forwards the request verbatim and
|
|
2390
|
-
* returns the aggregate + per-row result untouched.
|
|
2391
|
-
*
|
|
2392
|
-
* Requires `@objectstack/client` with `data.import` (server `/import` route).
|
|
2393
|
-
* Callers should feature-detect (`typeof dataSource.importRecords`) and fall
|
|
2394
|
-
* back to a per-row `create` loop when unavailable.
|
|
2395
|
-
*/
|
|
2396
|
-
async importRecords(
|
|
2397
|
-
resource: string,
|
|
2398
|
-
request: ImportRequestOptions,
|
|
2399
|
-
): Promise<ImportRecordsResult> {
|
|
2400
|
-
await this.connect();
|
|
2401
|
-
const importFn = (this.client.data as { import?: unknown }).import;
|
|
2402
|
-
if (typeof importFn !== 'function') {
|
|
2403
|
-
throw new ObjectStackError(
|
|
2404
|
-
'The connected @objectstack/client does not support data.import(). ' +
|
|
2405
|
-
'Upgrade the client, or import via a per-row create fallback.',
|
|
2406
|
-
'UNSUPPORTED_OPERATION',
|
|
2407
|
-
400,
|
|
2408
|
-
);
|
|
2409
|
-
}
|
|
2410
|
-
try {
|
|
2411
|
-
const result = await (importFn as (
|
|
2412
|
-
object: string,
|
|
2413
|
-
req: ImportRequestOptions,
|
|
2414
|
-
) => Promise<ImportRecordsResult>).call(this.client.data, resource, request);
|
|
2415
|
-
return result;
|
|
2416
|
-
} catch (err) {
|
|
2417
|
-
throw normaliseClientError(err);
|
|
2418
|
-
}
|
|
2419
|
-
}
|
|
2420
|
-
|
|
2421
|
-
/**
|
|
2422
|
-
* Feature-detect the async import-job API on the connected client. Older
|
|
2423
|
-
* clients/servers lack these routes; callers fall back to {@link importRecords}.
|
|
2424
|
-
*/
|
|
2425
|
-
private importJobApi(): {
|
|
2426
|
-
createImportJob: (object: string, req: ImportRequestOptions) => Promise<CreateImportJobResult>;
|
|
2427
|
-
getImportJobProgress: (jobId: string) => Promise<ImportJobProgressInfo>;
|
|
2428
|
-
getImportJobResults: (jobId: string) => Promise<ImportJobResultsInfo>;
|
|
2429
|
-
listImportJobs: (query: ListImportJobsOptions) => Promise<ImportJobSummaryInfo[]>;
|
|
2430
|
-
cancelImportJob: (jobId: string) => Promise<{ success: boolean }>;
|
|
2431
|
-
undoImportJob: (jobId: string) => Promise<ImportJobUndoResult>;
|
|
2432
|
-
} | undefined {
|
|
2433
|
-
const d = this.client.data as Record<string, unknown>;
|
|
2434
|
-
if (typeof d.createImportJob !== 'function') return undefined;
|
|
2435
|
-
return d as any;
|
|
2436
|
-
}
|
|
2437
|
-
|
|
2438
|
-
/**
|
|
2439
|
-
* Start an asynchronous import job — the large-file counterpart to
|
|
2440
|
-
* {@link importRecords}. Posts the whole payload once; the server processes
|
|
2441
|
-
* rows in the background. Requires an `@objectstack/client` new enough to
|
|
2442
|
-
* expose `data.createImportJob` (server `/import/jobs` route). Callers should
|
|
2443
|
-
* feature-detect (`typeof dataSource.createImportJob`) and fall back to the
|
|
2444
|
-
* synchronous path when unavailable.
|
|
2445
|
-
*/
|
|
2446
|
-
async createImportJob(
|
|
2447
|
-
resource: string,
|
|
2448
|
-
request: ImportRequestOptions,
|
|
2449
|
-
): Promise<CreateImportJobResult> {
|
|
2450
|
-
await this.connect();
|
|
2451
|
-
const api = this.importJobApi();
|
|
2452
|
-
if (!api) {
|
|
2453
|
-
throw new ObjectStackError(
|
|
2454
|
-
'The connected @objectstack/client does not support async import jobs (data.createImportJob). ' +
|
|
2455
|
-
'Upgrade the client, or use the synchronous importRecords() path.',
|
|
2456
|
-
'UNSUPPORTED_OPERATION',
|
|
2457
|
-
400,
|
|
2458
|
-
);
|
|
2459
|
-
}
|
|
2460
|
-
try {
|
|
2461
|
-
return await api.createImportJob.call(this.client.data, resource, request);
|
|
2462
|
-
} catch (err) {
|
|
2463
|
-
throw normaliseClientError(err);
|
|
2464
|
-
}
|
|
2465
|
-
}
|
|
2466
|
-
|
|
2467
|
-
/** Poll an import job's progress. Requires {@link createImportJob} support. */
|
|
2468
|
-
async getImportJobProgress(jobId: string): Promise<ImportJobProgressInfo> {
|
|
2469
|
-
await this.connect();
|
|
2470
|
-
const api = this.importJobApi();
|
|
2471
|
-
if (!api) {
|
|
2472
|
-
throw new ObjectStackError(
|
|
2473
|
-
'The connected @objectstack/client does not support async import jobs.',
|
|
2474
|
-
'UNSUPPORTED_OPERATION',
|
|
2475
|
-
400,
|
|
2476
|
-
);
|
|
2477
|
-
}
|
|
2478
|
-
try {
|
|
2479
|
-
return await api.getImportJobProgress.call(this.client.data, jobId);
|
|
2480
|
-
} catch (err) {
|
|
2481
|
-
throw normaliseClientError(err);
|
|
2482
|
-
}
|
|
2483
|
-
}
|
|
2484
|
-
|
|
2485
|
-
/** Fetch an import job's capped per-row results. */
|
|
2486
|
-
async getImportJobResults(jobId: string): Promise<ImportJobResultsInfo> {
|
|
2487
|
-
await this.connect();
|
|
2488
|
-
const api = this.importJobApi();
|
|
2489
|
-
if (!api) {
|
|
2490
|
-
throw new ObjectStackError(
|
|
2491
|
-
'The connected @objectstack/client does not support async import jobs.',
|
|
2492
|
-
'UNSUPPORTED_OPERATION',
|
|
2493
|
-
400,
|
|
2494
|
-
);
|
|
2495
|
-
}
|
|
2496
|
-
try {
|
|
2497
|
-
return await api.getImportJobResults.call(this.client.data, jobId);
|
|
2498
|
-
} catch (err) {
|
|
2499
|
-
throw normaliseClientError(err);
|
|
2500
|
-
}
|
|
2501
|
-
}
|
|
2502
|
-
|
|
2503
|
-
/** List recent import jobs (history), newest first. */
|
|
2504
|
-
async listImportJobs(options: ListImportJobsOptions = {}): Promise<ImportJobSummaryInfo[]> {
|
|
2505
|
-
await this.connect();
|
|
2506
|
-
const api = this.importJobApi();
|
|
2507
|
-
if (!api) {
|
|
2508
|
-
throw new ObjectStackError(
|
|
2509
|
-
'The connected @objectstack/client does not support async import jobs.',
|
|
2510
|
-
'UNSUPPORTED_OPERATION',
|
|
2511
|
-
400,
|
|
2512
|
-
);
|
|
2513
|
-
}
|
|
2514
|
-
try {
|
|
2515
|
-
return await api.listImportJobs.call(this.client.data, options);
|
|
2516
|
-
} catch (err) {
|
|
2517
|
-
throw normaliseClientError(err);
|
|
2518
|
-
}
|
|
2519
|
-
}
|
|
2520
|
-
|
|
2521
|
-
/** Cancel a pending/running import job (cooperative). */
|
|
2522
|
-
async cancelImportJob(jobId: string): Promise<void> {
|
|
2523
|
-
await this.connect();
|
|
2524
|
-
const api = this.importJobApi();
|
|
2525
|
-
if (!api) {
|
|
2526
|
-
throw new ObjectStackError(
|
|
2527
|
-
'The connected @objectstack/client does not support async import jobs.',
|
|
2528
|
-
'UNSUPPORTED_OPERATION',
|
|
2529
|
-
400,
|
|
2530
|
-
);
|
|
2531
|
-
}
|
|
2532
|
-
try {
|
|
2533
|
-
await api.cancelImportJob.call(this.client.data, jobId);
|
|
2534
|
-
} catch (err) {
|
|
2535
|
-
throw normaliseClientError(err);
|
|
2536
|
-
}
|
|
2537
|
-
}
|
|
2538
|
-
|
|
2539
|
-
/**
|
|
2540
|
-
* Logically roll back a finished import job — delete the records it created
|
|
2541
|
-
* and restore the records it updated to their pre-import values. Requires an
|
|
2542
|
-
* `@objectstack/client` new enough to expose `data.undoImportJob`, and a job
|
|
2543
|
-
* the server captured an undo log for (see {@link ImportJobProgressInfo.undoable}).
|
|
2544
|
-
*/
|
|
2545
|
-
async undoImportJob(jobId: string): Promise<ImportJobUndoResult> {
|
|
2546
|
-
await this.connect();
|
|
2547
|
-
const api = this.importJobApi();
|
|
2548
|
-
if (!api || typeof (api as { undoImportJob?: unknown }).undoImportJob !== 'function') {
|
|
2549
|
-
throw new ObjectStackError(
|
|
2550
|
-
'The connected @objectstack/client does not support undoing import jobs (data.undoImportJob).',
|
|
2551
|
-
'UNSUPPORTED_OPERATION',
|
|
2552
|
-
400,
|
|
2553
|
-
);
|
|
2554
|
-
}
|
|
2555
|
-
try {
|
|
2556
|
-
return await api.undoImportJob.call(this.client.data, jobId);
|
|
2557
|
-
} catch (err) {
|
|
2558
|
-
throw normaliseClientError(err);
|
|
2559
|
-
}
|
|
2560
|
-
}
|
|
2561
|
-
|
|
2562
|
-
/**
|
|
2563
|
-
* Normalize the result from data.find() or data.query() into a consistent QueryResult.
|
|
2564
|
-
*/
|
|
2565
|
-
private normalizeQueryResult(result: unknown, params?: QueryParams): QueryResult<T> {
|
|
2566
|
-
// Handle legacy/raw array response (e.g. from some mock servers or non-OData endpoints)
|
|
2567
|
-
if (Array.isArray(result)) {
|
|
2568
|
-
return {
|
|
2569
|
-
data: result,
|
|
2570
|
-
total: result.length,
|
|
2571
|
-
page: 1,
|
|
2572
|
-
pageSize: result.length,
|
|
2573
|
-
hasMore: false,
|
|
2574
|
-
};
|
|
2575
|
-
}
|
|
2576
|
-
|
|
2577
|
-
const resultObj = result as { records?: T[]; total?: number; value?: T[]; count?: number; hasMore?: boolean };
|
|
2578
|
-
const records = resultObj.records || resultObj.value || [];
|
|
2579
|
-
const total = resultObj.total ?? resultObj.count ?? records.length;
|
|
2580
|
-
// Prefer the server's `hasMore` (real server-side pagination, framework
|
|
2581
|
-
// issue #2212). Fall back to the page-local estimate (a full page implies
|
|
2582
|
-
// there may be more) only when the server doesn't report it.
|
|
2583
|
-
const hasMore = typeof resultObj.hasMore === 'boolean'
|
|
2584
|
-
? resultObj.hasMore
|
|
2585
|
-
: (params?.$top ? records.length === params.$top : false);
|
|
2586
|
-
return {
|
|
2587
|
-
data: records,
|
|
2588
|
-
total,
|
|
2589
|
-
// Calculate page number safely
|
|
2590
|
-
page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1,
|
|
2591
|
-
pageSize: params?.$top,
|
|
2592
|
-
hasMore,
|
|
2593
|
-
};
|
|
2594
|
-
}
|
|
2595
|
-
|
|
2596
|
-
/**
|
|
2597
|
-
* Make a raw GET request to the data API with `populate` as a URL query param.
|
|
2598
|
-
* Used when $expand is needed, since the client SDK's data.find() does not
|
|
2599
|
-
* support populate/expand. The server's REST API routes GET /data/:object
|
|
2600
|
-
* to findData({ object, query: req.query }) which processes `populate`.
|
|
2601
|
-
*/
|
|
2602
|
-
private async rawFindWithPopulate(resource: string, params: QueryParams): Promise<unknown> {
|
|
2603
|
-
const queryParams = new URLSearchParams();
|
|
2604
|
-
|
|
2605
|
-
// Populate: comma-separated field names for lookup expansion
|
|
2606
|
-
if (params.$expand && params.$expand.length > 0) {
|
|
2607
|
-
queryParams.set('populate', params.$expand.join(','));
|
|
2608
|
-
}
|
|
2609
|
-
|
|
2610
|
-
// Pagination
|
|
2611
|
-
if (params.$top !== undefined) {
|
|
2612
|
-
queryParams.set('top', String(params.$top));
|
|
2613
|
-
}
|
|
2614
|
-
if (params.$skip !== undefined) {
|
|
2615
|
-
queryParams.set('skip', String(params.$skip));
|
|
2616
|
-
}
|
|
2617
|
-
|
|
2618
|
-
// Full-text search (ADR-0061). The server resolves which fields to match
|
|
2619
|
-
// from object metadata; the client only sends the term (+ optional override).
|
|
2620
|
-
if (params.$search != null && String(params.$search).trim() !== '') {
|
|
2621
|
-
queryParams.set('search', String(params.$search).trim());
|
|
2622
|
-
}
|
|
2623
|
-
if (params.$searchFields && params.$searchFields.length > 0) {
|
|
2624
|
-
queryParams.set('searchFields', params.$searchFields.join(','));
|
|
2625
|
-
}
|
|
2626
|
-
|
|
2627
|
-
// Selection — always include `id` to ensure records can be identified
|
|
2628
|
-
// for navigation/selection even when callers omit it from $select.
|
|
2629
|
-
if (params.$select && params.$select.length > 0) {
|
|
2630
|
-
const selectFields = params.$select.includes('id')
|
|
2631
|
-
? params.$select
|
|
2632
|
-
: ['id', ...params.$select];
|
|
2633
|
-
queryParams.set('select', selectFields.join(','));
|
|
2634
|
-
}
|
|
2635
|
-
|
|
2636
|
-
// Sorting
|
|
2637
|
-
const sortStr = serializeOrderBy(params.$orderby);
|
|
2638
|
-
if (sortStr) queryParams.set('sort', sortStr);
|
|
2639
|
-
|
|
2640
|
-
// Filter — translate ViewFilterRule[] (`[{field, operator, value}]`)
|
|
2641
|
-
// and other shapes into AST tuples the server understands. Without this,
|
|
2642
|
-
// server-driven views (e.g. `at_risk_accounts`, `hot_leads`) ship raw
|
|
2643
|
-
// `[{field,operator,value}]` arrays which `parseFilterAST` silently
|
|
2644
|
-
// discards, returning every record instead of the filtered subset.
|
|
2645
|
-
if (params.$filter !== undefined && params.$filter !== null) {
|
|
2646
|
-
const translated = translateFilterToAST(params.$filter);
|
|
2647
|
-
if (translated !== undefined) {
|
|
2648
|
-
queryParams.set('filter', JSON.stringify(translated));
|
|
2649
|
-
}
|
|
2650
|
-
}
|
|
2651
|
-
|
|
2652
|
-
const baseUrl = this.baseUrl.replace(/\/$/, '');
|
|
2653
|
-
const qs = queryParams.toString();
|
|
2654
|
-
// Avoid doubling /api/v1 if baseUrl already includes it
|
|
2655
|
-
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
2656
|
-
const dataPath = hasApiVersionSuffix ? '/data' : '/api/v1/data';
|
|
2657
|
-
const url = `${baseUrl}${dataPath}/${resource}${qs ? `?${qs}` : ''}`;
|
|
2658
|
-
|
|
2659
|
-
const headers: Record<string, string> = {
|
|
2660
|
-
'Content-Type': 'application/json',
|
|
2661
|
-
};
|
|
2662
|
-
if (this.token) {
|
|
2663
|
-
headers['Authorization'] = `Bearer ${this.token}`;
|
|
2664
|
-
}
|
|
2665
|
-
|
|
2666
|
-
const res = await this.fetchImpl(url, { method: 'GET', headers });
|
|
2667
|
-
|
|
2668
|
-
if (!res.ok) {
|
|
2669
|
-
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
|
|
2670
|
-
const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText) as any;
|
|
2671
|
-
err.status = res.status;
|
|
2672
|
-
// Carry the ADR-0112 envelope, not just the status. This branch bypasses
|
|
2673
|
-
// `@objectstack/client` — whose fetch wrapper stamps `code`/`httpStatus`
|
|
2674
|
-
// from the error body — so dropping the code here made THIS path (the one
|
|
2675
|
-
// taken whenever the view expands a lookup or runs a search) the only list
|
|
2676
|
-
// fetch on which a semantic denial arrives indistinguishable from any
|
|
2677
|
-
// other 404/405, leaving the surface nothing to discriminate on
|
|
2678
|
-
// (objectui#4408). Same precedence as the client's wrapper: the top-level
|
|
2679
|
-
// `code` first, then the nested envelope's.
|
|
2680
|
-
err.code = errorBody?.code ?? errorBody?.error?.code;
|
|
2681
|
-
err.httpStatus = res.status;
|
|
2682
|
-
throw err;
|
|
2683
|
-
}
|
|
2684
|
-
|
|
2685
|
-
const body = await res.json();
|
|
2686
|
-
// Unwrap standard response envelope { success, data }
|
|
2687
|
-
if (body && typeof body.success === 'boolean' && 'data' in body) {
|
|
2688
|
-
return body.data;
|
|
2689
|
-
}
|
|
2690
|
-
return body;
|
|
2691
|
-
}
|
|
2692
|
-
|
|
2693
|
-
/**
|
|
2694
|
-
* Synchronously download a server-streamed export (csv / json / xlsx).
|
|
2695
|
-
*
|
|
2696
|
-
* Hits `GET /api/v1/data/:object/export`, which streams matching rows in the
|
|
2697
|
-
* requested format, formats values for readability (lookup → name, select →
|
|
2698
|
-
* label, boolean → 是/否, dates formatted) and enforces permissions. The
|
|
2699
|
-
* filter / sort are translated the same way as `rawFindWithPopulate` so the
|
|
2700
|
-
* exported file mirrors the active list view. Returns the file as a Blob;
|
|
2701
|
-
* the caller triggers the browser download.
|
|
2702
|
-
*/
|
|
2703
|
-
async exportDownload(resource: string, request: ExportDownloadRequest = {}): Promise<Blob> {
|
|
2704
|
-
const queryParams = new URLSearchParams();
|
|
2705
|
-
|
|
2706
|
-
const format = request.format === 'xlsx' ? 'xlsx' : request.format === 'json' ? 'json' : 'csv';
|
|
2707
|
-
queryParams.set('format', format);
|
|
2708
|
-
|
|
2709
|
-
if (request.fields && request.fields.length > 0) {
|
|
2710
|
-
queryParams.set('fields', request.fields.join(','));
|
|
2711
|
-
}
|
|
2712
|
-
if (request.limit && request.limit > 0) {
|
|
2713
|
-
queryParams.set('limit', String(request.limit));
|
|
2714
|
-
}
|
|
2715
|
-
if (request.includeHeaders === false) {
|
|
2716
|
-
queryParams.set('header', 'false');
|
|
2717
|
-
}
|
|
2718
|
-
// Sort → server `orderby` shorthand: "field:dir,field2:dir".
|
|
2719
|
-
if (request.sort && request.sort.length > 0) {
|
|
2720
|
-
const orderby = request.sort
|
|
2721
|
-
.filter(s => s && s.field)
|
|
2722
|
-
.map(s => `${s.field}:${s.direction === 'desc' ? 'desc' : 'asc'}`)
|
|
2723
|
-
.join(',');
|
|
2724
|
-
if (orderby) queryParams.set('orderby', orderby);
|
|
2725
|
-
}
|
|
2726
|
-
// Filter → AST tuples, same translation the list GET path uses.
|
|
2727
|
-
if (request.filter !== undefined && request.filter !== null) {
|
|
2728
|
-
const translated = translateFilterToAST(request.filter);
|
|
2729
|
-
if (translated !== undefined) {
|
|
2730
|
-
queryParams.set('filter', JSON.stringify(translated));
|
|
2731
|
-
}
|
|
2732
|
-
}
|
|
2733
|
-
// Search — the other half of what a list is showing. Without it an export
|
|
2734
|
-
// taken while a search is active returns the unsearched superset
|
|
2735
|
-
// (objectstack#4230). Servers predating that ignore the param.
|
|
2736
|
-
const searchTerm = typeof request.search === 'string' ? request.search.trim() : '';
|
|
2737
|
-
if (searchTerm) {
|
|
2738
|
-
queryParams.set('search', searchTerm);
|
|
2739
|
-
if (request.searchFields && request.searchFields.length > 0) {
|
|
2740
|
-
queryParams.set('searchFields', request.searchFields.join(','));
|
|
2741
|
-
}
|
|
2742
|
-
}
|
|
2743
|
-
|
|
2744
|
-
const baseUrl = this.baseUrl.replace(/\/$/, '');
|
|
2745
|
-
// Avoid doubling /api/v1 if baseUrl already includes the version suffix.
|
|
2746
|
-
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
2747
|
-
const dataPath = hasApiVersionSuffix ? '/data' : '/api/v1/data';
|
|
2748
|
-
const url = `${baseUrl}${dataPath}/${encodeURIComponent(resource)}/export?${queryParams.toString()}`;
|
|
2749
|
-
|
|
2750
|
-
const headers: Record<string, string> = { ...this.getAuthHeaders() };
|
|
2751
|
-
// `credentials: 'include'` carries the session cookie for the browser
|
|
2752
|
-
// console (which authenticates by cookie, not a bearer token).
|
|
2753
|
-
const res = await this.fetchImpl(url, { method: 'GET', headers, credentials: 'include' });
|
|
2754
|
-
if (!res.ok) {
|
|
2755
|
-
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
|
|
2756
|
-
const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText) as any;
|
|
2757
|
-
err.status = res.status;
|
|
2758
|
-
throw err;
|
|
2759
|
-
}
|
|
2760
|
-
return await res.blob();
|
|
2761
|
-
}
|
|
2762
|
-
|
|
2763
|
-
/**
|
|
2764
|
-
* Convert ObjectUI QueryParams to ObjectStack QueryOptions.
|
|
2765
|
-
* Maps OData-style conventions to ObjectStack conventions.
|
|
2766
|
-
*/
|
|
2767
|
-
private convertQueryParams(params?: QueryParams): ObjectStackQueryOptions {
|
|
2768
|
-
if (!params) return {};
|
|
2769
|
-
|
|
2770
|
-
const options: ObjectStackQueryOptions = {};
|
|
2771
|
-
|
|
2772
|
-
// Selection — always include `id` so records remain identifiable for
|
|
2773
|
-
// navigation/selection even when callers omit it from $select.
|
|
2774
|
-
if (params.$select) {
|
|
2775
|
-
options.select = params.$select.includes('id')
|
|
2776
|
-
? params.$select
|
|
2777
|
-
: ['id', ...params.$select];
|
|
2778
|
-
}
|
|
2779
|
-
|
|
2780
|
-
// Filtering - convert to ObjectStack FilterNode AST format. Treat empty
|
|
2781
|
-
// arrays/objects as "no filter" to avoid emitting `filter=[]` over the wire.
|
|
2782
|
-
if (params.$filter !== undefined && params.$filter !== null) {
|
|
2783
|
-
const isEmpty = Array.isArray(params.$filter)
|
|
2784
|
-
? params.$filter.length === 0
|
|
2785
|
-
: typeof params.$filter === 'object' && Object.keys(params.$filter).length === 0;
|
|
2786
|
-
if (!isEmpty) {
|
|
2787
|
-
if (Array.isArray(params.$filter)) {
|
|
2788
|
-
// Two array shapes are accepted from upstream:
|
|
2789
|
-
// 1. AST tuples: [field, op, value] — pass through.
|
|
2790
|
-
// 2. Object form: [{ field, operator, value }, ...] — server-driven
|
|
2791
|
-
// view configs (lead.view.ts etc.) use this. Translate each
|
|
2792
|
-
// entry into the AST tuple shape and map human-readable
|
|
2793
|
-
// operator names (`greater_than_or_equal`, `in`, `contains`,
|
|
2794
|
-
// …) to the canonical symbols the server understands.
|
|
2795
|
-
// Shared with `translateFilterToAST` so the two `find()` routes — this
|
|
2796
|
-
// one and the `$expand`/`$search` raw GET — cannot disagree about the
|
|
2797
|
-
// same stored filter.
|
|
2798
|
-
options.filters = translateFilterArray(params.$filter);
|
|
2799
|
-
} else {
|
|
2800
|
-
options.filters = convertFiltersToAST(params.$filter);
|
|
2801
|
-
}
|
|
2802
|
-
}
|
|
2803
|
-
}
|
|
2804
|
-
|
|
2805
|
-
// Sorting — the same serializer the raw GET route uses, so the two `find()`
|
|
2806
|
-
// paths cannot disagree about one stored sort. The client SDK's
|
|
2807
|
-
// `QueryOptions.sort` accepts the shorthand string directly.
|
|
2808
|
-
const sort = serializeOrderBy(params.$orderby);
|
|
2809
|
-
if (sort) options.sort = sort;
|
|
2810
|
-
|
|
2811
|
-
// Pagination
|
|
2812
|
-
if (params.$skip !== undefined) {
|
|
2813
|
-
options.skip = params.$skip;
|
|
2814
|
-
}
|
|
2815
|
-
|
|
2816
|
-
if (params.$top !== undefined) {
|
|
2817
|
-
options.top = params.$top;
|
|
2818
|
-
}
|
|
2819
|
-
|
|
2820
|
-
if (params.$search != null && String(params.$search).trim() !== '') {
|
|
2821
|
-
(options as Record<string, unknown>).search = String(params.$search).trim();
|
|
2822
|
-
}
|
|
2823
|
-
if (params.$searchFields && params.$searchFields.length > 0) {
|
|
2824
|
-
(options as Record<string, unknown>).searchFields = params.$searchFields;
|
|
2825
|
-
}
|
|
2826
|
-
|
|
2827
|
-
return options;
|
|
2828
|
-
}
|
|
2829
|
-
|
|
2830
|
-
/**
|
|
2831
|
-
* Get object schema/metadata from ObjectStack.
|
|
2832
|
-
* Uses caching to improve performance for repeated requests.
|
|
2833
|
-
*
|
|
2834
|
-
* @param objectName - Object name
|
|
2835
|
-
* @returns Promise resolving to the object schema
|
|
2836
|
-
*/
|
|
2837
|
-
async getObjectSchema(objectName: string): Promise<unknown> {
|
|
2838
|
-
await this.connect();
|
|
2839
|
-
|
|
2840
|
-
try {
|
|
2841
|
-
// Use cache with automatic fetching. The cache is keyed by object name
|
|
2842
|
-
// only (locale-independent); a language switch wipes it wholesale via
|
|
2843
|
-
// `clearCache()` so the next read re-fetches in the new locale — see the
|
|
2844
|
-
// shell's locale remount (issue #1319). Keeping the key locale-free here
|
|
2845
|
-
// means a metadata *write* still invalidates the single entry it knows
|
|
2846
|
-
// about, without having to fan out across every cached locale.
|
|
2847
|
-
// Read through a cache-revalidating fetch (see fetchObjectSchemaFresh):
|
|
2848
|
-
// the server marks single-object metadata `public, max-age=3600`, so a
|
|
2849
|
-
// plain fetch would keep serving the pre-publish schema from the browser
|
|
2850
|
-
// HTTP cache for up to an hour — and the create/edit form (which reads
|
|
2851
|
-
// getObjectSchema) would never show a field added + published in this
|
|
2852
|
-
// session. The list endpoint is uncached, which is why list views already
|
|
2853
|
-
// refresh on publish.
|
|
2854
|
-
const schema = await this.metadataCache.get(objectName, () =>
|
|
2855
|
-
this.fetchObjectSchemaFresh(objectName),
|
|
2856
|
-
);
|
|
2857
|
-
|
|
2858
|
-
// Canonicalize the relational-target key: the server names it
|
|
2859
|
-
// `reference` (ObjectStack convention) while most consumers read
|
|
2860
|
-
// `reference_to` (#2407 / PR #2587). Stamping both here — the choke
|
|
2861
|
-
// point every schema read goes through — means no per-consumer
|
|
2862
|
-
// dual-key fallback can drift. Idempotent on the cached object.
|
|
2863
|
-
normalizeSchemaReferenceKeys(schema);
|
|
2864
|
-
|
|
2865
|
-
// ADR-0056 P2 (epic #2398): stamp structured-widget hints onto specific
|
|
2866
|
-
// platform fields. This is the single choke point both the record form
|
|
2867
|
-
// (ObjectForm) and the detail view (DetailView/DetailSection) read the
|
|
2868
|
-
// schema through, so one pass here reaches every edit surface.
|
|
2869
|
-
this.applyFieldWidgetOverrides(objectName, schema);
|
|
2870
|
-
|
|
2871
|
-
return schema;
|
|
2872
|
-
} catch (error: unknown) {
|
|
2873
|
-
// Check if it's a 404 error
|
|
2874
|
-
const errorObj = error as Record<string, unknown>;
|
|
2875
|
-
if (is404Error(errorObj)) {
|
|
2876
|
-
throw new MetadataNotFoundError(objectName, { originalError: error });
|
|
2877
|
-
}
|
|
2878
|
-
|
|
2879
|
-
// For other errors, wrap in ObjectStackError if not already
|
|
2880
|
-
if (error instanceof ObjectStackError) {
|
|
2881
|
-
throw error;
|
|
2882
|
-
}
|
|
2883
|
-
|
|
2884
|
-
throw createErrorFromResponse(errorObj, `getObjectSchema(${objectName})`);
|
|
2885
|
-
}
|
|
2886
|
-
}
|
|
2887
|
-
|
|
2888
|
-
/**
|
|
2889
|
-
* ADR-0056 P2 (epic #2398) — stamp structured-widget hints onto platform
|
|
2890
|
-
* fields whose framework type is a storage primitive (e.g. `textarea`) but
|
|
2891
|
-
* whose authoring UX should be a structured editor. Only the render `widget`
|
|
2892
|
-
* is added; the field's `type` (the storage contract) is untouched. Applied
|
|
2893
|
-
* idempotently to the cached schema so form + detail both honor it. Widget
|
|
2894
|
-
* components are registered as `field:<widget>` in `@object-ui/fields`.
|
|
2895
|
-
*/
|
|
2896
|
-
private applyFieldWidgetOverrides(objectName: string, schema: unknown): void {
|
|
2897
|
-
const OVERRIDES: Record<string, Record<string, string>> = {
|
|
2898
|
-
// ADR-0056 pure model — a permission set's six authorization facets are
|
|
2899
|
-
// *designed* in Studio's structured editors and only *assigned* (to
|
|
2900
|
-
// users) in Setup. In Setup they render read-only as a summary + a
|
|
2901
|
-
// "Design in Studio →" deep-link (the `permission-facet-link` widget),
|
|
2902
|
-
// never as raw [Object]/JSON. The capability *editor* itself lives in
|
|
2903
|
-
// Studio (epic #2398 P2).
|
|
2904
|
-
sys_permission_set: {
|
|
2905
|
-
object_permissions: 'permission-facet-link',
|
|
2906
|
-
field_permissions: 'permission-facet-link',
|
|
2907
|
-
system_permissions: 'permission-facet-link',
|
|
2908
|
-
row_level_security: 'permission-facet-link',
|
|
2909
|
-
tab_permissions: 'permission-facet-link',
|
|
2910
|
-
admin_scope: 'permission-facet-link',
|
|
2911
|
-
},
|
|
2912
|
-
};
|
|
2913
|
-
const overrides = OVERRIDES[objectName];
|
|
2914
|
-
const fields =
|
|
2915
|
-
schema && typeof schema === 'object' ? (schema as { fields?: unknown }).fields : null;
|
|
2916
|
-
if (!overrides || !fields) return;
|
|
2917
|
-
for (const [fname, widget] of Object.entries(overrides)) {
|
|
2918
|
-
if (Array.isArray(fields)) {
|
|
2919
|
-
const f = fields.find((x: any) => x?.name === fname);
|
|
2920
|
-
if (f && !f.widget) f.widget = widget;
|
|
2921
|
-
} else {
|
|
2922
|
-
const f = (fields as Record<string, any>)[fname];
|
|
2923
|
-
if (f && !f.widget) f.widget = widget;
|
|
2924
|
-
}
|
|
2925
|
-
}
|
|
2926
|
-
}
|
|
2927
|
-
|
|
2928
|
-
/**
|
|
2929
|
-
* Fetch a single object's schema while always revalidating the browser cache.
|
|
2930
|
-
*
|
|
2931
|
-
* The server serves `GET /api/v1/meta/object/:name` with
|
|
2932
|
-
* `Cache-Control: public, max-age=3600`, so the default `fetch` the SDK uses
|
|
2933
|
-
* keeps returning the same response from the browser HTTP cache for up to an
|
|
2934
|
-
* hour without contacting the origin. Because the create/edit form reads the
|
|
2935
|
-
* object schema through {@link getObjectSchema}, a field added + published in
|
|
2936
|
-
* the same session never appears in the form even though it is live (the LIST
|
|
2937
|
-
* endpoint, `/meta/object`, is uncached — which is why list views update).
|
|
2938
|
-
*
|
|
2939
|
-
* Issuing the read with `cache: 'no-cache'` forces a conditional revalidation
|
|
2940
|
-
* (`If-None-Match`): a changed ETag returns the fresh schema, an unchanged one
|
|
2941
|
-
* still gets a cheap `304`. We go through `fetchImpl` (the adapter's
|
|
2942
|
-
* authenticated fetch) rather than `client.meta.getItem` because the SDK does
|
|
2943
|
-
* not expose the request cache mode.
|
|
2944
|
-
*/
|
|
2945
|
-
private async fetchObjectSchemaFresh(objectName: string): Promise<unknown> {
|
|
2946
|
-
const baseUrl = (this.baseUrl || '').replace(/\/$/, '');
|
|
2947
|
-
// Avoid doubling /api/v1 when baseUrl already carries the version suffix
|
|
2948
|
-
// (mirrors rawFindWithPopulate).
|
|
2949
|
-
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
2950
|
-
const metaPath = hasApiVersionSuffix ? '/meta' : '/api/v1/meta';
|
|
2951
|
-
const url = `${baseUrl}${metaPath}/object/${encodeURIComponent(objectName)}`;
|
|
2952
|
-
|
|
2953
|
-
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
2954
|
-
// Bearer (server-to-server) callers configure `this.token`; cookie/console
|
|
2955
|
-
// auth is injected by `fetchImpl` (the authenticated fetch wrapper).
|
|
2956
|
-
if (this.token) {
|
|
2957
|
-
headers['Authorization'] = `Bearer ${this.token}`;
|
|
2958
|
-
}
|
|
2959
|
-
|
|
2960
|
-
const res = await this.fetchImpl(url, {
|
|
2961
|
-
method: 'GET',
|
|
2962
|
-
headers,
|
|
2963
|
-
// Revalidate instead of serving the stale `max-age` body (see doc above).
|
|
2964
|
-
cache: 'no-cache',
|
|
2965
|
-
});
|
|
2966
|
-
|
|
2967
|
-
if (!res.ok) {
|
|
2968
|
-
const errBody: any = await res.json().catch(() => ({ message: res.statusText }));
|
|
2969
|
-
const err: any = new Error(errBody?.error?.message || errBody?.message || res.statusText);
|
|
2970
|
-
err.status = res.status;
|
|
2971
|
-
throw err;
|
|
2972
|
-
}
|
|
2973
|
-
|
|
2974
|
-
const body: any = await res.json();
|
|
2975
|
-
// Unwrap defensively across server/SDK response shapes: the standard
|
|
2976
|
-
// `{ success, data }` envelope, an `{ item }` wrapper, or the bare item.
|
|
2977
|
-
const data = body && typeof body === 'object' && 'success' in body && 'data' in body ? body.data : body;
|
|
2978
|
-
return data && typeof data === 'object' && 'item' in data ? data.item : data;
|
|
2979
|
-
}
|
|
2980
|
-
|
|
2981
|
-
/**
|
|
2982
|
-
* List every registered object (code- and DB-defined) from the metadata
|
|
2983
|
-
* registry — `GET /api/v1/meta/object`. Returns lightweight `{ name, label }`
|
|
2984
|
-
* headers for object-picker widgets (e.g. the sharing-rule `object-ref`
|
|
2985
|
-
* field). The list endpoint is uncached server-side, so no cache-busting
|
|
2986
|
-
* dance is needed. Returns `[]` on any failure so callers degrade gracefully.
|
|
2987
|
-
*/
|
|
2988
|
-
async getObjects(): Promise<Array<{ name: string; label?: string }>> {
|
|
2989
|
-
try {
|
|
2990
|
-
await this.connect();
|
|
2991
|
-
const baseUrl = (this.baseUrl || '').replace(/\/$/, '');
|
|
2992
|
-
// Avoid doubling /api/v1 when baseUrl already carries the version suffix
|
|
2993
|
-
// (mirrors fetchObjectSchemaFresh).
|
|
2994
|
-
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
2995
|
-
const metaPath = hasApiVersionSuffix ? '/meta' : '/api/v1/meta';
|
|
2996
|
-
const url = `${baseUrl}${metaPath}/object`;
|
|
2997
|
-
|
|
2998
|
-
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
2999
|
-
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
|
3000
|
-
|
|
3001
|
-
const res = await this.fetchImpl(url, { method: 'GET', headers });
|
|
3002
|
-
if (!res.ok) return [];
|
|
3003
|
-
const body: any = await res.json();
|
|
3004
|
-
// Unwrap the `{ success, data }` envelope, the `{ type, items }` list
|
|
3005
|
-
// shape, or a bare array.
|
|
3006
|
-
const data =
|
|
3007
|
-
body && typeof body === 'object' && 'success' in body && 'data' in body ? body.data : body;
|
|
3008
|
-
const items: any[] = Array.isArray(data)
|
|
3009
|
-
? data
|
|
3010
|
-
: Array.isArray(data?.items)
|
|
3011
|
-
? data.items
|
|
3012
|
-
: [];
|
|
3013
|
-
return items
|
|
3014
|
-
.map((it: any) => ({
|
|
3015
|
-
name: String(it?.name ?? ''),
|
|
3016
|
-
label: it?.label != null ? String(it.label) : undefined,
|
|
3017
|
-
}))
|
|
3018
|
-
.filter((it) => it.name);
|
|
3019
|
-
} catch {
|
|
3020
|
-
return [];
|
|
3021
|
-
}
|
|
3022
|
-
}
|
|
3023
|
-
|
|
3024
|
-
/**
|
|
3025
|
-
* Get access to the underlying ObjectStack client for advanced operations.
|
|
3026
|
-
*/
|
|
3027
|
-
getClient(): ObjectStackClient {
|
|
3028
|
-
return this.client;
|
|
3029
|
-
}
|
|
3030
|
-
|
|
3031
|
-
/**
|
|
3032
|
-
* Get the discovery information from the connected server.
|
|
3033
|
-
* Returns the capabilities and service status of the ObjectStack server.
|
|
3034
|
-
*
|
|
3035
|
-
* Note: This accesses an internal property of the ObjectStackClient.
|
|
3036
|
-
* The discovery data is populated during client.connect() and cached.
|
|
3037
|
-
*
|
|
3038
|
-
* @returns Promise resolving to discovery data, or null if not connected
|
|
3039
|
-
*/
|
|
3040
|
-
async getDiscovery(): Promise<unknown | null> {
|
|
3041
|
-
try {
|
|
3042
|
-
// Ensure we're connected first
|
|
3043
|
-
await this.connect();
|
|
3044
|
-
|
|
3045
|
-
// Access discovery data from the client
|
|
3046
|
-
// The ObjectStackClient caches discovery during connect()
|
|
3047
|
-
// This is an internal property, but documented for this use case
|
|
3048
|
-
// @ts-expect-error - Accessing internal discoveryInfo property
|
|
3049
|
-
return this.client.discoveryInfo || null;
|
|
3050
|
-
} catch {
|
|
3051
|
-
return null;
|
|
3052
|
-
}
|
|
3053
|
-
}
|
|
3054
|
-
|
|
3055
|
-
/**
|
|
3056
|
-
* **The one place that knows which cache keys a view-row write invalidates.**
|
|
3057
|
-
*
|
|
3058
|
-
* Two reads are cached under view-shaped keys, and both go stale when any
|
|
3059
|
-
* writer touches a view row for `objectName`:
|
|
3060
|
-
*
|
|
3061
|
-
* | reader | key |
|
|
3062
|
-
* |----------------------------|----------------------------|
|
|
3063
|
-
* | {@link getView} | `view:{object}:{viewName}` |
|
|
3064
|
-
* | {@link listViewOverrides} | `view-overrides:{object}` |
|
|
3065
|
-
*
|
|
3066
|
-
* Every writer routes here rather than restating that pair. The repo has now
|
|
3067
|
-
* paid twice for restatement: objectui#3778 removed five copies of a key
|
|
3068
|
-
* (`views:{object}`) that no reader had ever populated, and objectui#4363
|
|
3069
|
-
* fixed four copies that named only half the live set — and objectui#4373 is
|
|
3070
|
-
* the measured proof that the failure recurs by default, because the console's
|
|
3071
|
-
* real create/publish flow writes these same rows through the ADR-0034
|
|
3072
|
-
* metadata seam and never learned the key list at all. A pin suite can only
|
|
3073
|
-
* guard the writers that exist; a mandatory seam makes the next writer
|
|
3074
|
-
* structurally unable to forget.
|
|
3075
|
-
*
|
|
3076
|
-
* The rule is uniform per WRITE, not per branch: a write to a view row drops
|
|
3077
|
-
* both keys, in that order. Draft-addressed writes over-invalidate on purpose
|
|
3078
|
-
* — both readers enumerate PUBLISHED rows, so a draft write stales neither —
|
|
3079
|
-
* because the costs are not symmetric. An unnecessary invalidation costs one
|
|
3080
|
-
* refetch; a missed one costs up to the cache's 5-minute TTL of stale
|
|
3081
|
-
* overrides, and "which half am I in?" is not a question a future writer
|
|
3082
|
-
* should have to re-answer to stay correct.
|
|
3083
|
-
*
|
|
3084
|
-
* Public because the writers are not all in this class: app-shell's ADR-0034
|
|
3085
|
-
* seam (`runtime-metadata-persistence.ts`) and `MetadataService` write the
|
|
3086
|
-
* same rows through `client.meta` / the `/meta` draft-publish API, and they
|
|
3087
|
-
* reach this adapter as the `dataSource` they were already handed.
|
|
3088
|
-
*
|
|
3089
|
-
* @param objectName - Object the view belongs to (the `{object}` in both keys)
|
|
3090
|
-
* @param viewName - The view's canonical `name` — the identity
|
|
3091
|
-
* {@link getView} reads back by and {@link listViewOverrides} keys its map
|
|
3092
|
-
* with. Callers that hold a qualified `<object>.<key>` name pass it as-is:
|
|
3093
|
-
* it is the row key, not a path to be split.
|
|
3094
|
-
*/
|
|
3095
|
-
invalidateViewKeys(objectName: string, viewName: string): void {
|
|
3096
|
-
// Ordered per-view key first, then the object's map — the order the pin
|
|
3097
|
-
// suite records, so a reordering is a visible decision rather than drift.
|
|
3098
|
-
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
|
|
3099
|
-
this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
|
|
3100
|
-
}
|
|
3101
|
-
|
|
3102
|
-
/**
|
|
3103
|
-
* Batch-fetch all persisted view overrides for an object.
|
|
3104
|
-
*
|
|
3105
|
-
* Per-view runtime overrides (density, column widths, sort, hidden
|
|
3106
|
-
* columns, inlineEdit …) live in the SAME metadata namespace the
|
|
3107
|
-
* write path uses: `type='view'`, `name=<viewId>` (see
|
|
3108
|
-
* {@link updateViewConfig}). Loading them per-view fires N HTTP GETs
|
|
3109
|
-
* that 404 for every view the user never customized — console noise on
|
|
3110
|
-
* every page load. This batch method performs a single
|
|
3111
|
-
* `GET /api/v1/meta/view` (returns `{type, items}`) and narrows the
|
|
3112
|
-
* result to `objectName` client-side, exactly as {@link listViews}
|
|
3113
|
-
* does over the same rows (shared accessor: {@link viewItemObjectName}).
|
|
3114
|
-
*
|
|
3115
|
-
* objectui#3774 — this used to enumerate `GET /api/v1/meta/<objectName>`,
|
|
3116
|
-
* putting the OBJECT name in the metadata TYPE slot. That key space is
|
|
3117
|
-
* disjoint from the one the write path lands in, so the batch map came
|
|
3118
|
-
* back empty for every object, forever, and every saved personalization
|
|
3119
|
-
* read back as "setting didn't save".
|
|
3120
|
-
*
|
|
3121
|
-
* FAILURES REJECT — they are not answered as `{}`. An empty map is an
|
|
3122
|
-
* authoritative "this object has no overrides" (callers may trust it and
|
|
3123
|
-
* skip the per-view reads); a transport/permission failure is "I could
|
|
3124
|
-
* not tell", and reporting the two as the same value is what made
|
|
3125
|
-
* ObjectView's per-view {@link getView} fallback unreachable code. When
|
|
3126
|
-
* we cannot tell, we do not pretend we can. Rejections are not cached
|
|
3127
|
-
* (the cache stores on success only), so a transient failure does not
|
|
3128
|
-
* pin an empty answer for the TTL.
|
|
3129
|
-
*
|
|
3130
|
-
* Result is cached identically to {@link getView}, and EVERY view write —
|
|
3131
|
-
* on this adapter and above it — invalidates it through the one seam,
|
|
3132
|
-
* {@link invalidateViewKeys}. For a long time only {@link updateViewConfig}
|
|
3133
|
-
* did (objectui#4363), which left the other three adapter paths stale
|
|
3134
|
-
* for the cache's 5-minute TTL. That gap does not self-heal: the consumer
|
|
3135
|
-
* (`loadViewOverrides`, app-shell `ObjectView`) treats a RESOLVED map as
|
|
3136
|
-
* authoritative and deliberately does not re-probe per view — objectui#3774,
|
|
3137
|
-
* and correct, since re-probing reinstates the 404 flurry the batch read
|
|
3138
|
-
* exists to remove. So a stale map here is served in full, and the per-view
|
|
3139
|
-
* {@link getView} fallback that would have masked it never runs.
|
|
3140
|
-
*
|
|
3141
|
-
* @param objectName - Object name (e.g. 'lead')
|
|
3142
|
-
* @returns Map keyed by view name with the persisted override config
|
|
3143
|
-
* @throws whatever the metadata transport throws — callers that have a
|
|
3144
|
-
* per-view fallback should catch and use it.
|
|
3145
|
-
*/
|
|
3146
|
-
async listViewOverrides(objectName: string): Promise<Record<string, any>> {
|
|
3147
|
-
await this.connect();
|
|
3148
|
-
|
|
3149
|
-
const cacheKey = `view-overrides:${objectName}`;
|
|
3150
|
-
return await this.metadataCache.get(cacheKey, async () => {
|
|
3151
|
-
const result: any = await this.client.meta.getItems('view');
|
|
3152
|
-
const items: any[] = Array.isArray(result?.items)
|
|
3153
|
-
? result.items
|
|
3154
|
-
: Array.isArray(result) ? result : [];
|
|
3155
|
-
const out: Record<string, any> = {};
|
|
3156
|
-
for (const it of items) {
|
|
3157
|
-
if (!it || typeof it !== 'object') continue;
|
|
3158
|
-
if (viewItemObjectName(it) !== objectName) continue;
|
|
3159
|
-
// Keyed by the item's canonical `name` — the SAME identity
|
|
3160
|
-
// `updateViewConfig` writes under and `getView` reads back by, which
|
|
3161
|
-
// is what makes this a drop-in substitute for the per-view fetch.
|
|
3162
|
-
// No `?? id ?? _name` alias chain: those are not view identities on
|
|
3163
|
-
// any route (`/meta/view/:name` is name-addressed), and a batch map
|
|
3164
|
-
// keyed by something no caller can ask for is dead weight.
|
|
3165
|
-
const key = it.name;
|
|
3166
|
-
if (typeof key === 'string' && key) out[key] = it;
|
|
3167
|
-
}
|
|
3168
|
-
return out;
|
|
3169
|
-
});
|
|
3170
|
-
}
|
|
3171
|
-
|
|
3172
|
-
/**
|
|
3173
|
-
* Get a view definition for an object.
|
|
3174
|
-
* Attempts to fetch from the server metadata API.
|
|
3175
|
-
* Falls back to null if the server doesn't provide view definitions,
|
|
3176
|
-
* allowing the consumer to use static config.
|
|
3177
|
-
*
|
|
3178
|
-
* @param objectName - Object name
|
|
3179
|
-
* @param viewId - View identifier
|
|
3180
|
-
* @returns Promise resolving to the view definition or null
|
|
3181
|
-
*/
|
|
3182
|
-
async getView(objectName: string, viewId: string): Promise<unknown | null> {
|
|
3183
|
-
await this.connect();
|
|
3184
|
-
|
|
3185
|
-
try {
|
|
3186
|
-
const cacheKey = `view:${objectName}:${viewId}`;
|
|
3187
|
-
return await this.metadataCache.get(cacheKey, async () => {
|
|
3188
|
-
// Views are an independent metadata type (ADR-0017) — the first
|
|
3189
|
-
// getItem argument is the metadata TYPE, not the object name.
|
|
3190
|
-
// (Passing objectName here hit /meta/<object>/<view> and always 404ed.)
|
|
3191
|
-
const result: any = await this.client.meta.getItem('view', viewId);
|
|
3192
|
-
if (result && result.item) return result.item;
|
|
3193
|
-
return result ?? null;
|
|
3194
|
-
});
|
|
3195
|
-
} catch {
|
|
3196
|
-
// Server doesn't support view metadata — return null to fall back to static config
|
|
3197
|
-
return null;
|
|
3198
|
-
}
|
|
3199
|
-
}
|
|
3200
|
-
|
|
3201
|
-
/**
|
|
3202
|
-
* Persist a view definition for an object.
|
|
3203
|
-
*
|
|
3204
|
-
* Symmetric counterpart to {@link getView}: writes the view to the
|
|
3205
|
-
* server metadata store via `client.meta.saveItem`, then invalidates
|
|
3206
|
-
* the matching cache entry so the next {@link getView} reflects the
|
|
3207
|
-
* new payload. Returns the persisted item when the server echoes it,
|
|
3208
|
-
* otherwise undefined.
|
|
3209
|
-
*
|
|
3210
|
-
* Used by ObjectView for "live" toolbar persistence (density,
|
|
3211
|
-
* column widths, sort, etc.) and by the View Config Panel for
|
|
3212
|
-
* explicit saves.
|
|
3213
|
-
*
|
|
3214
|
-
* @param objectName - Object name (e.g. 'lead')
|
|
3215
|
-
* @param viewId - View identifier (e.g. 'all_leads')
|
|
3216
|
-
* @param config - Full view definition to persist
|
|
3217
|
-
*/
|
|
3218
|
-
async updateViewConfig(
|
|
3219
|
-
objectName: string,
|
|
3220
|
-
viewId: string,
|
|
3221
|
-
config: Record<string, any>
|
|
3222
|
-
): Promise<Record<string, any> | void> {
|
|
3223
|
-
await this.connect();
|
|
3224
|
-
// ADR-0005 metadata customization overlay: persist views under
|
|
3225
|
-
// `type='view'` (NOT `type=<objectName>` — that was a pre-overlay
|
|
3226
|
-
// misuse that hit `/api/v1/meta/<objectName>/<viewId>`, which the
|
|
3227
|
-
// server never wired). The view's `data.object` field is what
|
|
3228
|
-
// associates it back to the object on read.
|
|
3229
|
-
const merged = { ...(config || {}), object: (config as any)?.object || objectName, name: viewId };
|
|
3230
|
-
const result: any = await this.client.meta.saveItem(
|
|
3231
|
-
'view',
|
|
3232
|
-
viewId,
|
|
3233
|
-
merged
|
|
3234
|
-
);
|
|
3235
|
-
// One seam names the key set for every writer (objectui#4373).
|
|
3236
|
-
this.invalidateViewKeys(objectName, viewId);
|
|
3237
|
-
if (result && result.item) return result.item;
|
|
3238
|
-
return result ?? undefined;
|
|
3239
|
-
}
|
|
3240
|
-
|
|
3241
|
-
/**
|
|
3242
|
-
* List user-created views for a given object via the metadata overlay
|
|
3243
|
-
* API (ADR-0005). Replaces the legacy `find('sys_view', {...})` path
|
|
3244
|
-
* that wrote to a physical `sys_view` table whose columns no longer
|
|
3245
|
-
* match the view spec shape.
|
|
3246
|
-
*
|
|
3247
|
-
* Returns view spec objects with their canonical `name` as identifier.
|
|
3248
|
-
* Narrows to one object client-side via {@link viewItemObjectName} —
|
|
3249
|
-
* the metadata index is name-only, not field-typed, so the route has no
|
|
3250
|
-
* `?object=` to push the filter down into. {@link listViewOverrides}
|
|
3251
|
-
* reads the same rows through the same accessor.
|
|
3252
|
-
*/
|
|
3253
|
-
async listViews(
|
|
3254
|
-
objectName: string,
|
|
3255
|
-
options?: { previewDrafts?: boolean },
|
|
3256
|
-
): Promise<any[]> {
|
|
3257
|
-
await this.connect();
|
|
3258
|
-
try {
|
|
3259
|
-
let items: any[];
|
|
3260
|
-
if (options?.previewDrafts) {
|
|
3261
|
-
// ADR-0037 + #2767 (P2/P3): a SINGLE `?preview=draft` request already
|
|
3262
|
-
// returns the active+draft OVERLAID list — draft wins by name,
|
|
3263
|
-
// draft-only views surface, each draft tagged `_draft: true`. So we
|
|
3264
|
-
// REPLACE the published list wholesale (never fetch both and append,
|
|
3265
|
-
// which double-lists a draft that edits a published view). Route it
|
|
3266
|
-
// through `MetadataClient` rather than a hand-rolled fetch so the
|
|
3267
|
-
// metadata route + any environment scoping stay in one place.
|
|
3268
|
-
const draftItems = await this.metadataClient()
|
|
3269
|
-
.withPreviewDrafts(true)
|
|
3270
|
-
.list<any>('view');
|
|
3271
|
-
items = Array.isArray(draftItems) ? draftItems : [];
|
|
3272
|
-
} else {
|
|
3273
|
-
const result: any = await this.client.meta.getItems('view');
|
|
3274
|
-
items = Array.isArray(result?.items)
|
|
3275
|
-
? result.items
|
|
3276
|
-
: Array.isArray(result) ? result : [];
|
|
3277
|
-
}
|
|
3278
|
-
// This feeds the list-view switcher (ViewTabBar), so it must return
|
|
3279
|
-
// LIST-family views only. The backend now exposes each view as an
|
|
3280
|
-
// independent ViewItem carrying a `viewKind` discriminant (ADR-0017);
|
|
3281
|
-
// form-family views (`form`/`detail`) are record forms, not list tabs,
|
|
3282
|
-
// and must be excluded — otherwise e.g. `crm_activity.default` (a form)
|
|
3283
|
-
// leaks in as a spurious switcher tab. Bare specs without `viewKind`
|
|
3284
|
-
// (legacy artifacts / saved views) are kept as list views.
|
|
3285
|
-
const FORM_FAMILY = new Set(['form', 'detail']);
|
|
3286
|
-
return items.filter((v: any) => {
|
|
3287
|
-
if (!v) return false;
|
|
3288
|
-
// Handle both bare view spec and `{list: {...}}` artifact wrapper
|
|
3289
|
-
const spec = v.list ?? v;
|
|
3290
|
-
if (viewItemObjectName(v) !== objectName) return false;
|
|
3291
|
-
const viewKind = v.viewKind ?? spec?.viewKind;
|
|
3292
|
-
return !(viewKind && FORM_FAMILY.has(viewKind));
|
|
3293
|
-
}).map((v: any) => {
|
|
3294
|
-
const spec = v.list ?? v;
|
|
3295
|
-
// Preserve the draft provenance flag so the switcher can badge an
|
|
3296
|
-
// unpublished view (ADR-0037). The overlay tags the item, not its
|
|
3297
|
-
// nested spec, so read from either.
|
|
3298
|
-
const isDraft = v._draft === true || spec?._draft === true;
|
|
3299
|
-
// Canonical ViewItem (ADR-0017) carries its body under `config`;
|
|
3300
|
-
// the display `type` (grid/kanban/gallery/…) lives at `config.type`,
|
|
3301
|
-
// and only the list/form *family* sits at the top level (`viewKind`).
|
|
3302
|
-
// Flatten `config` up to the legacy NamedListView shape the switcher +
|
|
3303
|
-
// ObjectView consume — mirroring MetadataProvider.mergeViewsIntoObjects
|
|
3304
|
-
// so the two paths don't drift. Without this an un-flattened item has
|
|
3305
|
-
// no top-level `type`, so ObjectView's saved-view normalization defaults
|
|
3306
|
-
// it to 'grid' and overrides the metadata entry — a kanban/gallery/
|
|
3307
|
-
// calendar view then silently renders as a plain table.
|
|
3308
|
-
if (spec && spec.config && typeof spec.config === 'object') {
|
|
3309
|
-
return {
|
|
3310
|
-
...spec.config,
|
|
3311
|
-
name: spec.name ?? spec.config.name,
|
|
3312
|
-
label: spec.label ?? spec.config.label,
|
|
3313
|
-
isDefault: !!spec.isDefault,
|
|
3314
|
-
...(isDraft ? { _draft: true } : {}),
|
|
3315
|
-
};
|
|
3316
|
-
}
|
|
3317
|
-
return isDraft ? { ...spec, _draft: true } : spec;
|
|
3318
|
-
});
|
|
3319
|
-
} catch (err) {
|
|
3320
|
-
console.warn('[OBJECTSTACKDataSource] listViews failed:', err);
|
|
3321
|
-
return [];
|
|
3322
|
-
}
|
|
3323
|
-
}
|
|
3324
|
-
|
|
3325
|
-
/**
|
|
3326
|
-
* Build a {@link MetadataClient} bound to this adapter's server + auth. Used
|
|
3327
|
-
* by draft-aware reads (`listViews({ previewDrafts })`) so the `/meta` route,
|
|
3328
|
-
* `?preview=draft` flag, and environment scoping live in the SDK rather than
|
|
3329
|
-
* being hand-assembled at each call site (#2767 P3).
|
|
3330
|
-
*/
|
|
3331
|
-
private metadataClient(): MetadataClient {
|
|
3332
|
-
return new MetadataClient({
|
|
3333
|
-
baseUrl: this.baseUrl,
|
|
3334
|
-
fetch: this.fetchImpl,
|
|
3335
|
-
...(this.token ? { headers: { Authorization: `Bearer ${this.token}` } } : {}),
|
|
3336
|
-
});
|
|
3337
|
-
}
|
|
3338
|
-
|
|
3339
|
-
/**
|
|
3340
|
-
* List registered import `mapping` artifacts targeting a given object
|
|
3341
|
-
* (framework #2611). Reads the `mapping` metadata kind via the overlay API
|
|
3342
|
-
* and filters by `targetObject` client-side (the metadata index is
|
|
3343
|
-
* name-only). Feeds the import wizard's "saved mapping" selector; a failure
|
|
3344
|
-
* (older server without the `mapping` kind) degrades to an empty list, so
|
|
3345
|
-
* the selector simply doesn't appear.
|
|
3346
|
-
*/
|
|
3347
|
-
async listImportMappings(objectName: string): Promise<any[]> {
|
|
3348
|
-
await this.connect();
|
|
3349
|
-
try {
|
|
3350
|
-
const result: any = await this.client.meta.getItems('mapping');
|
|
3351
|
-
const items: any[] = Array.isArray(result?.items)
|
|
3352
|
-
? result.items
|
|
3353
|
-
: Array.isArray(result) ? result : [];
|
|
3354
|
-
return items.filter((m: any) => m && m.targetObject === objectName);
|
|
3355
|
-
} catch (err) {
|
|
3356
|
-
console.warn('[OBJECTSTACKDataSource] listImportMappings failed:', err);
|
|
3357
|
-
return [];
|
|
3358
|
-
}
|
|
3359
|
-
}
|
|
3360
|
-
|
|
3361
|
-
/**
|
|
3362
|
-
* Create a new overlay view for an object. The view's `name` is the
|
|
3363
|
-
* stable identifier — must be unique within the project scope. Returns
|
|
3364
|
-
* the persisted view spec (or undefined when the server doesn't echo).
|
|
3365
|
-
*
|
|
3366
|
-
* Generates a snake_case name if `spec.name` is not provided by appending
|
|
3367
|
-
* a short timestamp suffix to the source-name hint.
|
|
3368
|
-
*
|
|
3369
|
-
* Invalidates through {@link invalidateViewKeys}, like every other writer —
|
|
3370
|
-
* see {@link listViewOverrides} for why the batch map is the one that cannot
|
|
3371
|
-
* heal itself (objectui#4363).
|
|
3372
|
-
*/
|
|
3373
|
-
async createView(
|
|
3374
|
-
objectName: string,
|
|
3375
|
-
spec: Record<string, any>,
|
|
3376
|
-
): Promise<Record<string, any> | void> {
|
|
3377
|
-
await this.connect();
|
|
3378
|
-
let name = String(spec?.name || '').trim();
|
|
3379
|
-
if (!name) {
|
|
3380
|
-
let base = String(spec?.label || objectName || 'view')
|
|
3381
|
-
.toLowerCase()
|
|
3382
|
-
.replace(/[^a-z0-9_]+/g, '_')
|
|
3383
|
-
.replace(/^_+|_+$/g, '')
|
|
3384
|
-
.slice(0, 40);
|
|
3385
|
-
// Spec requires snake_case starting with a letter or underscore.
|
|
3386
|
-
// Labels like "表格 1" collapse to "1" after non-ascii stripping, so we
|
|
3387
|
-
// need a fallback / prefix to keep the identifier valid.
|
|
3388
|
-
if (!base || /^[0-9]/.test(base)) {
|
|
3389
|
-
base = base ? `view_${base}` : 'view';
|
|
3390
|
-
}
|
|
3391
|
-
const suffix = Date.now().toString(36);
|
|
3392
|
-
name = `${base}_${suffix}`;
|
|
3393
|
-
}
|
|
3394
|
-
const fullSpec = {
|
|
3395
|
-
...spec,
|
|
3396
|
-
name,
|
|
3397
|
-
object: spec?.object || objectName,
|
|
3398
|
-
data: spec?.data || { provider: 'object', object: objectName },
|
|
3399
|
-
};
|
|
3400
|
-
const result: any = await this.client.meta.saveItem('view', name, fullSpec);
|
|
3401
|
-
// `saveItem` is an UPSERT: an explicit `spec.name` that already exists
|
|
3402
|
-
// overwrites the published row, which a prior `getView` may hold. (A
|
|
3403
|
-
// generated name cannot collide, and a miss is a `Map.delete` on an absent
|
|
3404
|
-
// key, so the uniform rule costs nothing on the create-a-new-row path.)
|
|
3405
|
-
this.invalidateViewKeys(objectName, name);
|
|
3406
|
-
if (result && result.item) return result.item;
|
|
3407
|
-
return fullSpec;
|
|
3408
|
-
}
|
|
3409
|
-
|
|
3410
|
-
/**
|
|
3411
|
-
* Apply a partial update to an existing overlay view. Reads the current
|
|
3412
|
-
* document, merges, and writes it back. ADR-0005 overlay rows store the
|
|
3413
|
-
* *full* view document, so partial updates require a read-merge-write cycle.
|
|
3414
|
-
*
|
|
3415
|
-
* **Both halves address the same row (#4139).** A view has two possible
|
|
3416
|
-
* homes and the read must resolve the one the write will target:
|
|
3417
|
-
*
|
|
3418
|
-
* - a pending per-item **draft** (`?state=draft` / `?mode=draft`) — where
|
|
3419
|
-
* ADR-0034 stages every runtime-created view, so a view made from the `+`
|
|
3420
|
-
* tab lives ONLY here until an explicit Publish;
|
|
3421
|
-
* - the **published** overlay (`client.meta.getItem` / `saveItem`).
|
|
3422
|
-
*
|
|
3423
|
-
* The draft is probed FIRST, and a hit is merged and written straight back
|
|
3424
|
-
* as a draft. Two things that ordering buys, both load-bearing:
|
|
3425
|
-
*
|
|
3426
|
-
* 1. A draft-only view is no longer invisible to the read. It used to 404,
|
|
3427
|
-
* and a `catch {}` labelled "treat missing as create-equivalent"
|
|
3428
|
-
* substituted `current = {}` — so a rename merged onto nothing and went
|
|
3429
|
-
* out as a `{label, name, object}` partial the server rejects (422),
|
|
3430
|
-
* while the draft row the UI reads back through `?preview=draft` kept the
|
|
3431
|
-
* old label. The edit was lost with no error surfaced to the user.
|
|
3432
|
-
* 2. A draft is never bypassed. Writing the published row while a draft is
|
|
3433
|
-
* pending would put the edit somewhere the draft shadows — and Publish
|
|
3434
|
-
* would then overwrite it with the pre-edit body, losing the change a
|
|
3435
|
-
* second time, later, where nothing connects it to this call.
|
|
3436
|
-
*
|
|
3437
|
-
* A draft edit stays a draft: `mode: 'draft'` keeps ADR-0037's guarantee
|
|
3438
|
-
* that nothing the preview shows goes live until Publish. Renaming a
|
|
3439
|
-
* *published* view (no draft pending) is unchanged — it writes the
|
|
3440
|
-
* published overlay, as before.
|
|
3441
|
-
*
|
|
3442
|
-
* **Both halves invalidate through {@link invalidateViewKeys}** (objectui#4363,
|
|
3443
|
-
* #4373) — same call, same key set, so the draft half's deliberate
|
|
3444
|
-
* over-invalidation is the seam's uniform rule rather than a per-branch
|
|
3445
|
-
* decision this method has to keep re-making.
|
|
3446
|
-
*
|
|
3447
|
-
* @throws when the view resolves in neither home, or when either read fails
|
|
3448
|
-
* for any other reason (network, permission). Both used to be swallowed
|
|
3449
|
-
* and converted into the bad partial write above; a caller that wants a
|
|
3450
|
-
* view created should call {@link createView}, which is the operation that
|
|
3451
|
-
* actually means "create".
|
|
3452
|
-
*/
|
|
3453
|
-
async updateView(
|
|
3454
|
-
objectName: string,
|
|
3455
|
-
viewName: string,
|
|
3456
|
-
partial: Record<string, any>,
|
|
3457
|
-
): Promise<Record<string, any> | void> {
|
|
3458
|
-
await this.connect();
|
|
3459
|
-
|
|
3460
|
-
// ── Draft-addressed path ────────────────────────────────────────────
|
|
3461
|
-
// `MetadataClient.get` answers `null` on 404 (no draft pending) and throws
|
|
3462
|
-
// on anything else, so a transport failure here is NOT read as "published".
|
|
3463
|
-
const metaClient = this.metadataClient();
|
|
3464
|
-
const draft = unwrapViewDraft(
|
|
3465
|
-
await metaClient.get('view', viewName, { state: 'draft' }),
|
|
3466
|
-
);
|
|
3467
|
-
if (draft) {
|
|
3468
|
-
const mergedDraft = mergeViewPatch(draft, partial, viewName, objectName);
|
|
3469
|
-
await metaClient.save('view', viewName, mergedDraft, { mode: 'draft' });
|
|
3470
|
-
this.invalidateViewKeys(objectName, viewName);
|
|
3471
|
-
return mergedDraft;
|
|
3472
|
-
}
|
|
3473
|
-
|
|
3474
|
-
// ── Published-overlay path (unchanged addressing) ────────────────────
|
|
3475
|
-
let current: any;
|
|
3476
|
-
try {
|
|
3477
|
-
const r: any = await this.client.meta.getItem('view', viewName);
|
|
3478
|
-
current = (r && (r.item || r)) || {};
|
|
3479
|
-
// Some endpoints return the bare item; others wrap as {type,name,item}
|
|
3480
|
-
if (current?.list) current = current.list;
|
|
3481
|
-
} catch (err) {
|
|
3482
|
-
if (is404Error(err)) {
|
|
3483
|
-
// Not a draft, not published, not an artifact — there is nothing to
|
|
3484
|
-
// merge onto. Fail loudly instead of emitting the partial write.
|
|
3485
|
-
throw Object.assign(
|
|
3486
|
-
new Error(
|
|
3487
|
-
`updateView: view "${viewName}" not found on object "${objectName}"` +
|
|
3488
|
-
' — no pending draft and no published overlay. Use createView() to create one.',
|
|
3489
|
-
),
|
|
3490
|
-
{ cause: err },
|
|
3491
|
-
);
|
|
3492
|
-
}
|
|
3493
|
-
// Network / permission / server fault: surface it. Degrading to a
|
|
3494
|
-
// create-equivalent write here is what corrupted the row before.
|
|
3495
|
-
throw err;
|
|
3496
|
-
}
|
|
3497
|
-
|
|
3498
|
-
const merged = mergeViewPatch(current, partial, viewName, objectName);
|
|
3499
|
-
const result: any = await this.client.meta.saveItem('view', viewName, merged);
|
|
3500
|
-
this.invalidateViewKeys(objectName, viewName);
|
|
3501
|
-
if (result && result.item) return result.item;
|
|
3502
|
-
return merged;
|
|
3503
|
-
}
|
|
3504
|
-
|
|
3505
|
-
/**
|
|
3506
|
-
* Delete an overlay view — from **every home it has**.
|
|
3507
|
-
*
|
|
3508
|
-
* A view has two possible homes, the same two {@link updateView} addresses:
|
|
3509
|
-
* the pending per-item **draft** (`DELETE /meta/view/:name?state=draft`) and
|
|
3510
|
-
* the **published** overlay (`DELETE /meta/view/:name`). This method used to
|
|
3511
|
-
* issue only the second, and the three cases came out like this (#4479):
|
|
3512
|
-
*
|
|
3513
|
-
* | case | before |
|
|
3514
|
-
* |--------------------|-----------------------------------------------------|
|
|
3515
|
-
* | draft-only view | BUG — the published delete answered `reset:false` / |
|
|
3516
|
-
* | | "nothing to delete", the draft survived, and the tab |
|
|
3517
|
-
* | | was still there after reload |
|
|
3518
|
-
* | published-only | correct — `reset:true`, tab gone |
|
|
3519
|
-
* | published + draft | ACCIDENTALLY correct — the published row went, so |
|
|
3520
|
-
* | | the tab went; the orphan draft stayed behind |
|
|
3521
|
-
*
|
|
3522
|
-
* **Why this is not the mechanical mirror of #4139.** `updateView` probes
|
|
3523
|
-
* the draft first and writes back to whichever home the read resolved, and
|
|
3524
|
-
* that is right for an update in all three cases. Copying it here would be
|
|
3525
|
-
* wrong in the third: `persistRuntimeMetadata` (app-shell) stages EVERY
|
|
3526
|
-
* runtime edit as a draft, so "publish a view, then edit it" routinely
|
|
3527
|
-
* produces a pair — and a draft-first-only delete on a pair discards the
|
|
3528
|
-
* draft and leaves the published row serving the view. That is not Delete
|
|
3529
|
-
* view, it is **Discard draft**, a deliberately different operation that
|
|
3530
|
-
* already exists (`discardRuntimeDraft`, documented as "the published
|
|
3531
|
-
* overlay is untouched"). The clean statement of the asymmetry: for an
|
|
3532
|
-
* update, one home is the right home; for a delete, "remove this view" is
|
|
3533
|
-
* satisfied only when NO home is left serving it.
|
|
3534
|
-
*
|
|
3535
|
-
* So both homes are deleted, **draft first**. The order is load-bearing on
|
|
3536
|
-
* the failure path: a fault between the two calls leaves the PUBLISHED
|
|
3537
|
-
* overlay intact, so the view is still served and the operation is cleanly
|
|
3538
|
-
* retryable. The reverse order would strand a draft-only view — which is
|
|
3539
|
-
* precisely the bug shape above.
|
|
3540
|
-
*
|
|
3541
|
-
* **Two blind calls, no probe.** The framework's `deleteMetaItem` answers a
|
|
3542
|
-
* missing home with a **200** carrying `reset:false` (`"No pending draft
|
|
3543
|
-
* for view/x."` / `"No view 'x' found — nothing to delete."`), never a 404,
|
|
3544
|
-
* so there is nothing for a probe to protect against. `updateView` needs its
|
|
3545
|
-
* probe for a different reason — its read must resolve the row the merge
|
|
3546
|
-
* writes back to — and that reason has no counterpart for a delete.
|
|
3547
|
-
*
|
|
3548
|
-
* **One transport, one error contract.** Both halves go through
|
|
3549
|
-
* {@link MetadataClient} (`reset`), the transport that can express the
|
|
3550
|
-
* `?state=` qualifier and the one `updateView`'s draft half already uses.
|
|
3551
|
-
* The published half used to go through `client.meta.deleteItem`; measured,
|
|
3552
|
-
* that issues the byte-identical request (`DELETE
|
|
3553
|
-
* {baseUrl}/api/v1/meta/view/:name`, no environment scoping is configured on
|
|
3554
|
-
* this adapter), so routing it here costs no addressing change and buys a
|
|
3555
|
-
* single `MetadataError` shape across both calls instead of two.
|
|
3556
|
-
*
|
|
3557
|
-
* @returns `deleted` is true only when no home is left serving the view AND
|
|
3558
|
-
* at least one actually held a row. A view that existed in neither home
|
|
3559
|
-
* still answers `false`, unchanged. The per-home outcomes are additive:
|
|
3560
|
-
* a partial result is observable rather than rounded up to `true`.
|
|
3561
|
-
* @throws when either delete fails, matching {@link updateView}'s
|
|
3562
|
-
* convention of surfacing the fault rather than degrading. A failure of
|
|
3563
|
-
* the PUBLISHED half after the draft was discarded carries the partial
|
|
3564
|
-
* state on the error's `outcome` — "draft gone, overlay left" is exactly
|
|
3565
|
-
* what the old `{ deleted: boolean }` could not express.
|
|
3566
|
-
*
|
|
3567
|
-
* Invalidates through {@link invalidateViewKeys}: the deleted row leaves the
|
|
3568
|
-
* batch override map too, and a ghost entry there is what the object page
|
|
3569
|
-
* would keep applying (objectui#4363). Fired in a `finally`, so it happens
|
|
3570
|
-
* once per call on EVERY outcome including the throw — after a half-failure
|
|
3571
|
-
* the draft row really is gone, and #4363's asymmetry decides it: an
|
|
3572
|
-
* unnecessary invalidation costs one refetch, a missed one costs the cache's
|
|
3573
|
-
* full 5-minute TTL of stale overrides.
|
|
3574
|
-
*/
|
|
3575
|
-
async deleteView(
|
|
3576
|
-
objectName: string,
|
|
3577
|
-
viewName: string,
|
|
3578
|
-
): Promise<DeleteViewResult> {
|
|
3579
|
-
await this.connect();
|
|
3580
|
-
const metaClient = this.metadataClient();
|
|
3581
|
-
try {
|
|
3582
|
-
// ── Draft home, first ────────────────────────────────────────────────
|
|
3583
|
-
const draft = readViewDeleteReceipt(
|
|
3584
|
-
await metaClient.reset('view', viewName, { state: 'draft' }),
|
|
3585
|
-
);
|
|
3586
|
-
|
|
3587
|
-
// ── Published home ───────────────────────────────────────────────────
|
|
3588
|
-
let published: ViewHomeDeleteOutcome;
|
|
3589
|
-
try {
|
|
3590
|
-
published = readViewDeleteReceipt(await metaClient.reset('view', viewName));
|
|
3591
|
-
} catch (err) {
|
|
3592
|
-
const outcome: DeleteViewResult = {
|
|
3593
|
-
deleted: false,
|
|
3594
|
-
draft,
|
|
3595
|
-
published: { removed: false },
|
|
3596
|
-
};
|
|
3597
|
-
throw Object.assign(
|
|
3598
|
-
new Error(
|
|
3599
|
-
`deleteView: view "${viewName}" on object "${objectName}" is NOT fully removed` +
|
|
3600
|
-
` — the draft home was ${draft.removed ? 'discarded' : 'already absent'},` +
|
|
3601
|
-
' but deleting the published overlay failed. The published row is still' +
|
|
3602
|
-
' serving the view; retry the delete.',
|
|
3603
|
-
),
|
|
3604
|
-
{ cause: err, outcome },
|
|
3605
|
-
);
|
|
3606
|
-
}
|
|
3607
|
-
|
|
3608
|
-
return { deleted: draft.removed || published.removed, draft, published };
|
|
3609
|
-
} finally {
|
|
3610
|
-
this.invalidateViewKeys(objectName, viewName);
|
|
3611
|
-
}
|
|
3612
|
-
}
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
/**
|
|
3616
|
-
* Get an application definition by name or ID.
|
|
3617
|
-
* Attempts to fetch from the server metadata API.
|
|
3618
|
-
* Falls back to null if the server doesn't provide app definitions,
|
|
3619
|
-
* allowing the consumer to use static config.
|
|
3620
|
-
*
|
|
3621
|
-
* @param appId - Application identifier
|
|
3622
|
-
* @returns Promise resolving to the app definition or null
|
|
3623
|
-
*/
|
|
3624
|
-
async getApp(appId: string): Promise<unknown | null> {
|
|
3625
|
-
await this.connect();
|
|
3626
|
-
|
|
3627
|
-
try {
|
|
3628
|
-
const cacheKey = `app:${appId}`;
|
|
3629
|
-
return await this.metadataCache.get(cacheKey, async () => {
|
|
3630
|
-
const result: any = await this.client.meta.getItem('apps', appId);
|
|
3631
|
-
if (result && result.item) return result.item;
|
|
3632
|
-
return result ?? null;
|
|
3633
|
-
});
|
|
3634
|
-
} catch {
|
|
3635
|
-
// Server doesn't support app metadata — return null to fall back to static config
|
|
3636
|
-
return null;
|
|
3637
|
-
}
|
|
3638
|
-
}
|
|
3639
|
-
|
|
3640
|
-
/**
|
|
3641
|
-
* Ask the by-name meta app route WHY an app is not in this session's app list
|
|
3642
|
-
* (objectui#4252).
|
|
3643
|
-
*
|
|
3644
|
-
* `GET /api/v1/meta/apps` is filtered per session server-side
|
|
3645
|
-
* (`filterAppForUser`), so an app withheld by `requiredPermissions` and an app
|
|
3646
|
-
* that does not exist are byte-identical there: both are simply absent. A
|
|
3647
|
-
* console reading only that list has one fact and two conditions, and it
|
|
3648
|
-
* renders its copy for the wrong one — "it may still be publishing" over a
|
|
3649
|
-
* permanent authorization decision, which cost a downstream acceptance round
|
|
3650
|
-
* two test batches spent chasing a platform defect that was a missing
|
|
3651
|
-
* permission-set binding.
|
|
3652
|
-
*
|
|
3653
|
-
* The maintainer ruling (2026-08-12) put the answer on the BY-NAME route
|
|
3654
|
-
* rather than in the list, so the enumeration surface is not widened past what
|
|
3655
|
-
* a by-name probe already implies (objectstack#8013 / PR #8135): an app that
|
|
3656
|
-
* exists and whose `requiredPermissions` the session lacks answers `403` with
|
|
3657
|
-
* `PERMISSION_DENIED` in the declared envelope, and absence — a nonexistent
|
|
3658
|
-
* name, an unpublished app, an app gated by an absent optional service —
|
|
3659
|
-
* keeps answering `404 RESOURCE_NOT_FOUND`.
|
|
3660
|
-
*
|
|
3661
|
-
* ## Why this is a separate method and not a flavour of {@link getApp}
|
|
3662
|
-
*
|
|
3663
|
-
* - `getApp` degrades EVERY failure to `null`, which is exactly the
|
|
3664
|
-
* conflation this exists to undo; changing it would silently re-point its
|
|
3665
|
-
* own callers' fallback-to-static-config path.
|
|
3666
|
-
* - `getApp` memoises in `metadataCache`. A verdict about the CALLER must not
|
|
3667
|
-
* be cached beside a document about the APP — one grant, and a cached
|
|
3668
|
-
* denial outlives the session it described.
|
|
3669
|
-
*
|
|
3670
|
-
* Nothing here throws: a probe that cannot reach an answer returns `unknown`
|
|
3671
|
-
* and the caller keeps whatever it was already showing. Only the measured
|
|
3672
|
-
* `code` produces `denied` — never a status, never a message (objectui#4408).
|
|
3673
|
-
*
|
|
3674
|
-
* @param appName - the app name as it appears in the URL segment
|
|
3675
|
-
*/
|
|
3676
|
-
async probeAppAccess(appName: string): Promise<AppAccessVerdict> {
|
|
3677
|
-
if (!appName) return 'unknown';
|
|
3678
|
-
try {
|
|
3679
|
-
// Singular `app`, the address objectstack#8013 pinned its cases against,
|
|
3680
|
-
// and the same one `MetadataProvider` reads items by. No `connect()`
|
|
3681
|
-
// first: this route is only ever asked after the app LIST has already
|
|
3682
|
-
// loaded through this same client, and the client's route resolution
|
|
3683
|
-
// falls back to the conventional `/api/v1/meta` regardless — so a
|
|
3684
|
-
// discovery round trip here could only add a failure mode.
|
|
3685
|
-
await this.client.meta.getItem('app', appName);
|
|
3686
|
-
return 'granted';
|
|
3687
|
-
} catch (err) {
|
|
3688
|
-
return isAppPermissionDeniedError(err) ? 'denied' : 'unknown';
|
|
3689
|
-
}
|
|
3690
|
-
}
|
|
3691
|
-
|
|
3692
|
-
/**
|
|
3693
|
-
* Get a page definition from ObjectStack.
|
|
3694
|
-
* Uses the metadata API to fetch page layouts.
|
|
3695
|
-
* Returns null if the server doesn't support page metadata.
|
|
3696
|
-
*/
|
|
3697
|
-
async getPage(pageId: string): Promise<unknown | null> {
|
|
3698
|
-
await this.connect();
|
|
3699
|
-
|
|
3700
|
-
try {
|
|
3701
|
-
const cacheKey = `page:${pageId}`;
|
|
3702
|
-
return await this.metadataCache.get(cacheKey, async () => {
|
|
3703
|
-
const result: any = await this.client.meta.getItem('pages', pageId);
|
|
3704
|
-
if (result && result.item) return result.item;
|
|
3705
|
-
return result ?? null;
|
|
3706
|
-
});
|
|
3707
|
-
} catch {
|
|
3708
|
-
// Server doesn't support page metadata — return null to fall back to static config
|
|
3709
|
-
return null;
|
|
3710
|
-
}
|
|
3711
|
-
}
|
|
3712
|
-
|
|
3713
|
-
/**
|
|
3714
|
-
* Update (upsert) a dashboard definition.
|
|
3715
|
-
*
|
|
3716
|
-
* Dashboards are control-plane metadata, not data records. Persist via
|
|
3717
|
-
* `client.meta.saveItem('dashboard', name, schema)` which routes to
|
|
3718
|
-
* `PUT /api/v1/meta/dashboard/:name`. After save, invalidates the
|
|
3719
|
-
* relevant metadata cache entry so the next dashboard read reflects
|
|
3720
|
-
* the new payload.
|
|
3721
|
-
*
|
|
3722
|
-
* @param dashboardName - Dashboard identifier (e.g. 'crm_overview_dashboard')
|
|
3723
|
-
* @param schema - Full dashboard schema (widgets, layout, etc.)
|
|
3724
|
-
*/
|
|
3725
|
-
async updateDashboard(
|
|
3726
|
-
dashboardName: string,
|
|
3727
|
-
schema: Record<string, any>
|
|
3728
|
-
): Promise<Record<string, any> | void> {
|
|
3729
|
-
await this.connect();
|
|
3730
|
-
const result: any = await this.client.meta.saveItem(
|
|
3731
|
-
'dashboard',
|
|
3732
|
-
dashboardName,
|
|
3733
|
-
schema
|
|
3734
|
-
);
|
|
3735
|
-
// Invalidate dashboards list and any cached dashboard read so the
|
|
3736
|
-
// next render reflects the change.
|
|
3737
|
-
this.metadataCache.invalidate?.('dashboards');
|
|
3738
|
-
this.metadataCache.invalidate?.(`dashboard:${dashboardName}`);
|
|
3739
|
-
if (result && result.item) return result.item;
|
|
3740
|
-
return result ?? undefined;
|
|
3741
|
-
}
|
|
3742
|
-
|
|
3743
|
-
/**
|
|
3744
|
-
* Perform server-side aggregation via the ObjectStack analytics API.
|
|
3745
|
-
* Uses `this.client.analytics.query()` from @objectstack/client to leverage
|
|
3746
|
-
* the SDK's built-in auth, headers, and fetch configuration.
|
|
3747
|
-
* Falls back to client-side aggregation via find() if the analytics endpoint
|
|
3748
|
-
* is not available.
|
|
3749
|
-
*/
|
|
3750
|
-
async aggregate(resource: string, params: any): Promise<any[]> {
|
|
3751
|
-
await this.connect();
|
|
3752
|
-
|
|
3753
|
-
// Spec-shape aggregation: `{ groupBy: GroupByNode[], aggregations: AggregationNode[], where?, limit? }`
|
|
3754
|
-
// per spec/data/query.zod.ts. Sent directly to the server's POST
|
|
3755
|
-
// /data/:object/query endpoint, which routes through engine.aggregate
|
|
3756
|
-
// and returns bucketed rows with the requested aliases.
|
|
3757
|
-
const looksLikeSpecShape =
|
|
3758
|
-
params != null &&
|
|
3759
|
-
(Array.isArray((params as any).groupBy) ||
|
|
3760
|
-
Array.isArray((params as any).aggregations) ||
|
|
3761
|
-
(params as any).where !== undefined);
|
|
3762
|
-
if (looksLikeSpecShape) {
|
|
3763
|
-
const queryAst: Record<string, unknown> = {};
|
|
3764
|
-
if (Array.isArray(params.groupBy)) queryAst.groupBy = params.groupBy;
|
|
3765
|
-
if (Array.isArray(params.aggregations)) queryAst.aggregations = params.aggregations;
|
|
3766
|
-
if (params.where !== undefined) queryAst.where = params.where;
|
|
3767
|
-
if (typeof params.limit === 'number') queryAst.limit = params.limit;
|
|
3768
|
-
const result: any = await this.client.data.query(resource, queryAst as any);
|
|
3769
|
-
// client.data.query returns { object, records, total, hasMore }
|
|
3770
|
-
if (Array.isArray(result)) return result;
|
|
3771
|
-
if (Array.isArray(result?.records)) return result.records;
|
|
3772
|
-
if (Array.isArray(result?.data)) return result.data;
|
|
3773
|
-
return [];
|
|
3774
|
-
}
|
|
3775
|
-
|
|
3776
|
-
try {
|
|
3777
|
-
// Build measure name in the format expected by the backend analytics
|
|
3778
|
-
// service (memory-analytics / cube). For 'count' the measure key is
|
|
3779
|
-
// simply 'count'; for other aggregation functions it follows the
|
|
3780
|
-
// convention `${field}_${function}` (e.g. 'amount_sum').
|
|
3781
|
-
const measureName = params.function === 'count'
|
|
3782
|
-
? 'count'
|
|
3783
|
-
: `${params.field}_${params.function}`;
|
|
3784
|
-
// The column the caller expects the value under — the raw `field`, or the
|
|
3785
|
-
// literal `count` when a count names no field (framework#3701). Reading
|
|
3786
|
-
// `params.field` directly here keyed the row `undefined` for a fieldless
|
|
3787
|
-
// count and deleted the `count` the server sent, so the chart plotted
|
|
3788
|
-
// nothing.
|
|
3789
|
-
const valueKey = this.aggregateValueKey(params);
|
|
3790
|
-
|
|
3791
|
-
const payload: Record<string, unknown> = {
|
|
3792
|
-
cube: resource,
|
|
3793
|
-
measures: [measureName],
|
|
3794
|
-
// When groupBy is '_all' no dimensions are needed (single-bucket).
|
|
3795
|
-
dimensions: params.groupBy && params.groupBy !== '_all' ? [params.groupBy] : [],
|
|
3796
|
-
};
|
|
3797
|
-
if (params.filter) {
|
|
3798
|
-
// Dashboard widgets emit MongoDB-style FilterCondition (per
|
|
3799
|
-
// spec/ui/dashboard.zod.ts). Send via the canonical `where`
|
|
3800
|
-
// field of the analytics endpoint, matching the unified Query
|
|
3801
|
-
// DSL (spec/data/query.zod.ts).
|
|
3802
|
-
payload.where = params.filter;
|
|
3803
|
-
}
|
|
3804
|
-
|
|
3805
|
-
const data = await this.client.analytics.query(payload);
|
|
3806
|
-
|
|
3807
|
-
const rawRows: any[] = Array.isArray(data) ? data
|
|
3808
|
-
: data?.rows && Array.isArray(data.rows) ? data.rows
|
|
3809
|
-
: data?.data && Array.isArray(data.data) ? data.data
|
|
3810
|
-
: data?.data?.rows && Array.isArray(data.data.rows) ? data.data.rows
|
|
3811
|
-
: data?.results && Array.isArray(data.results) ? data.results
|
|
3812
|
-
: [];
|
|
3813
|
-
|
|
3814
|
-
// Defensive guard: if the backend silently dropped the requested measure
|
|
3815
|
-
// (e.g. it doesn't recognise the `${field}_${function}` alias and the
|
|
3816
|
-
// canonical measure is named differently), the rows come back without
|
|
3817
|
-
// any measure value. Detect this and fall back to client-side
|
|
3818
|
-
// aggregation so charts still render.
|
|
3819
|
-
const measureMissing = rawRows.length > 0 && rawRows.every((row: any) => {
|
|
3820
|
-
if (row == null) return true;
|
|
3821
|
-
if (measureName in row && row[measureName] != null) return false;
|
|
3822
|
-
if (valueKey in row && row[valueKey] != null) return false;
|
|
3823
|
-
return true;
|
|
3824
|
-
});
|
|
3825
|
-
if (measureMissing) {
|
|
3826
|
-
return await this.aggregateViaFind(resource, params);
|
|
3827
|
-
}
|
|
3828
|
-
|
|
3829
|
-
// Map measure keys back to the object-bound result column so consumers
|
|
3830
|
-
// (ObjectChart, DashboardRenderer, …) read values by the name the
|
|
3831
|
-
// convention promises: `field`, or `count` for a fieldless count
|
|
3832
|
-
// (framework#3701). This includes count → field (e.g. 'count' →
|
|
3833
|
-
// 'amount'), matching aggregateClientSide()'s output.
|
|
3834
|
-
return rawRows.map((row: any) => {
|
|
3835
|
-
const mapped = { ...row };
|
|
3836
|
-
if (measureName !== valueKey && measureName in mapped) {
|
|
3837
|
-
mapped[valueKey] = mapped[measureName];
|
|
3838
|
-
delete mapped[measureName];
|
|
3839
|
-
}
|
|
3840
|
-
return mapped;
|
|
3841
|
-
});
|
|
3842
|
-
} catch (e) {
|
|
3843
|
-
const failure = classifyAnalyticsFailure(e);
|
|
3844
|
-
|
|
3845
|
-
// The server refused OUR body — that is a defect in this adapter's
|
|
3846
|
-
// request, not a deployment without the capability. Answering it with
|
|
3847
|
-
// the client-side path would produce plausible numbers and bury the
|
|
3848
|
-
// contract violation, the misdirection framework#3878 documented.
|
|
3849
|
-
if (failure.kind === 'rejected') {
|
|
3850
|
-
throw new AnalyticsQueryRejectedError(failure.message, failure.code);
|
|
3851
|
-
}
|
|
3852
|
-
|
|
3853
|
-
// The capability is absent (404 — framework#4019 stops mounting the
|
|
3854
|
-
// routes when no analytics service is registered — or 501). Say so ONCE
|
|
3855
|
-
// so an operator sees a missing capability rather than charts that
|
|
3856
|
-
// quietly read from a slower path forever.
|
|
3857
|
-
if (failure.kind === 'not-installed') {
|
|
3858
|
-
this.warnAnalyticsCapabilityOnce(failure.message);
|
|
3859
|
-
}
|
|
3860
|
-
|
|
3861
|
-
// Degrade to find() + client-side aggregation. `aggregateViaFind`
|
|
3862
|
-
// forwards the same filter, so the fallback aggregates over the SAME row
|
|
3863
|
-
// set the server-side query would have — and `find()` is server-scoped,
|
|
3864
|
-
// so RLS still applies.
|
|
3865
|
-
return await this.aggregateViaFind(resource, params);
|
|
3866
|
-
}
|
|
3867
|
-
}
|
|
3868
|
-
|
|
3869
|
-
/**
|
|
3870
|
-
* Client-side aggregation over a server-scoped `find()` — the fallback used
|
|
3871
|
-
* whenever the analytics endpoint cannot answer (capability absent, network
|
|
3872
|
-
* failure, or a result whose measure came back missing).
|
|
3873
|
-
*
|
|
3874
|
-
* Forwarding `params.filter` is load-bearing: without it the fallback
|
|
3875
|
-
* aggregates the whole table while the caller believes it applied a filter,
|
|
3876
|
-
* which is the "KPI silently sums everything" failure this adapter has
|
|
3877
|
-
* guarded against since the widget filter was threaded through.
|
|
3878
|
-
*/
|
|
3879
|
-
private async aggregateViaFind(resource: string, params: any): Promise<any[]> {
|
|
3880
|
-
const result = await this.find(
|
|
3881
|
-
resource as any,
|
|
3882
|
-
params.filter ? ({ $filter: params.filter } as any) : undefined,
|
|
3883
|
-
);
|
|
3884
|
-
const records = result.data || [];
|
|
3885
|
-
if (records.length === 0) return [];
|
|
3886
|
-
return this.aggregateClientSide(records, params);
|
|
3887
|
-
}
|
|
3888
|
-
|
|
3889
|
-
/**
|
|
3890
|
-
* Say "the analytics capability isn't installed" ONCE per adapter, not once
|
|
3891
|
-
* per widget: a dashboard fans out one aggregate() per KPI, and N identical
|
|
3892
|
-
* console lines read like N different failures.
|
|
3893
|
-
*/
|
|
3894
|
-
private warnAnalyticsCapabilityOnce(detail?: string): void {
|
|
3895
|
-
if (this.analyticsCapabilityWarned) return;
|
|
3896
|
-
this.analyticsCapabilityWarned = true;
|
|
3897
|
-
console.warn(
|
|
3898
|
-
'[OBJECTSTACKDataSource] analytics capability unavailable — aggregating client-side ' +
|
|
3899
|
-
'from a scoped find(). Numbers stay correct but the semantic layer (cubes, joins, ' +
|
|
3900
|
-
'server-side rollups) is off. Install @objectstack/service-analytics to enable it.' +
|
|
3901
|
-
(detail ? ` Server said: ${detail}` : ''),
|
|
3902
|
-
);
|
|
3903
|
-
}
|
|
3904
|
-
|
|
3905
|
-
/**
|
|
3906
|
-
* Run a semantic-layer `dataset` (ADR-0021) and return chart-ready rows.
|
|
3907
|
-
*
|
|
3908
|
-
* Posts to `POST /api/v1/analytics/dataset/query` (see `@objectstack/rest`
|
|
3909
|
-
* `registerAnalyticsEndpoints`). Accepts either a saved dataset name or an
|
|
3910
|
-
* inline draft definition — the inline form is what the Studio dataset
|
|
3911
|
-
* editor sends to preview an unsaved draft. The adapter's bearer token is
|
|
3912
|
-
* forwarded so tenant/RLS scoping (ADR-0021 D-C) is enforced server-side.
|
|
3913
|
-
*
|
|
3914
|
-
* Unlike {@link aggregate}, this does NOT fall back to client-side
|
|
3915
|
-
* aggregation: cross-object joins can only run on the server, so a failure
|
|
3916
|
-
* is surfaced to the caller (the preview panel shows the error) rather than
|
|
3917
|
-
* silently returning wrong numbers.
|
|
3918
|
-
*
|
|
3919
|
-
* @param dataset - An inline dataset definition (draft) OR a saved dataset name.
|
|
3920
|
-
* @param selection - The spec's {@link DatasetSelection} — dimension/measure
|
|
3921
|
-
* names to project plus runtime directives. This parameter IS the spec type
|
|
3922
|
-
* by reference, never a local restatement of it (objectui#3613): a hand
|
|
3923
|
-
* copy of a contract is a second dialect of it, and the copy this replaced
|
|
3924
|
-
* had already drifted three ways from `@objectstack/spec` — it required
|
|
3925
|
-
* `compareTo.dimension` (optional since objectstack#5011, and resolved by
|
|
3926
|
-
* the EXECUTOR, so requiring it pushed callers into exactly the
|
|
3927
|
-
* consumer-side dimension guess AGENTS.md #0.1 forbids), it widened
|
|
3928
|
-
* `timeDimensions` to `unknown[]` and `runtimeFilter` to
|
|
3929
|
-
* `Record<string, unknown>`, and it had never grown `dateGranularity` at
|
|
3930
|
-
* all. Pinned in `queryDataset.test.ts`.
|
|
3931
|
-
*/
|
|
3932
|
-
async queryDataset(
|
|
3933
|
-
dataset: Record<string, unknown> | string,
|
|
3934
|
-
selection: DatasetSelection,
|
|
3935
|
-
): Promise<{
|
|
3936
|
-
rows: Array<Record<string, unknown>>;
|
|
3937
|
-
/**
|
|
3938
|
-
* Column metadata — the spec's `AnalyticsResult.fields[]` element BY
|
|
3939
|
-
* REFERENCE, never a local restatement of it (objectui#3752). Read
|
|
3940
|
-
* `@objectstack/spec` for what a column carries; this comment deliberately
|
|
3941
|
-
* does not re-list the keys, because the enumeration it replaced was the
|
|
3942
|
-
* bug: it named five (`name`/`type`/`label`/`format`/`currency`) and stopped
|
|
3943
|
-
* at the contract of the day it was written, so it never grew
|
|
3944
|
-
* `percentScale` — the server's answer to whether a percentage column is a
|
|
3945
|
-
* 0–1 fraction or already percentage points. The spec says a renderer that
|
|
3946
|
-
* receives it "must scale by it instead of guessing from the value"
|
|
3947
|
-
* (objectui#3136), so a declaration that hides the key steers a typed
|
|
3948
|
-
* consumer into exactly the guess-by-magnitude the issue banned. Pinned in
|
|
3949
|
-
* `queryDataset.test.ts`.
|
|
3950
|
-
*
|
|
3951
|
-
* Only this element is spec-owned: the envelope around it (`object` /
|
|
3952
|
-
* `dimensionFields` / `drillRawRows`) is ADR-0021 D2 drill metadata the REST
|
|
3953
|
-
* route adds on top of `AnalyticsResult`, and this method never returns the
|
|
3954
|
-
* result's `sql`, so the whole envelope is NOT an `AnalyticsResult`.
|
|
3955
|
-
*/
|
|
3956
|
-
fields: Array<AnalyticsResult['fields'][number]>;
|
|
3957
|
-
/** ADR-0021 D2 drill-through: the dataset's base object (records to drill into). */
|
|
3958
|
-
object?: string;
|
|
3959
|
-
/** Drillable dimension NAME → underlying object FIELD name. */
|
|
3960
|
-
dimensionFields?: Record<string, string>;
|
|
3961
|
-
/** Raw grouped values per row (aligned to `rows` by index) for drill filters. */
|
|
3962
|
-
drillRawRows?: Array<Record<string, unknown>>;
|
|
3963
|
-
/**
|
|
3964
|
-
* Half-open date-range drill scope per row (framework#1752), aligned to
|
|
3965
|
-
* `rows` by index: dimension NAME → the field and `[gte, lt)` bounds of that
|
|
3966
|
-
* row's time bucket. The RANGE companion to `drillRawRows`, which handles
|
|
3967
|
-
* equality dims only — a `dateGranularity` dimension groups a SPAN of
|
|
3968
|
-
* records into one bucket, so the server excludes date dims from
|
|
3969
|
-
* `dimensionFields`/`drillRawRows` and sends this sidecar instead.
|
|
3970
|
-
*
|
|
3971
|
-
* The entry type is `@object-ui/core`'s `DatasetDrillRange` BY REFERENCE,
|
|
3972
|
-
* not a local restatement of it (objectui#3613/#3752 discipline): the same
|
|
3973
|
-
* declaration is what `buildDatasetDrillFilter` — the single consumer that
|
|
3974
|
-
* turns these bounds into an ObjectQL `{ $gte, $lt }` — accepts, and what
|
|
3975
|
-
* `DatasetWidget` / `DatasetReportRenderer` type their state with. Nothing
|
|
3976
|
-
* in `@objectstack/spec` owns this shape yet (the server's own
|
|
3977
|
-
* `AnalyticsResultWithDrill` is local to `service-analytics`), so the shared
|
|
3978
|
-
* in-repo interface is the one contract available; restating it here would
|
|
3979
|
-
* make a third dialect of it. Like `drillRawRows`, only the ARRAY is
|
|
3980
|
-
* validated below — the bounds are unvalidated payload, which is exactly why
|
|
3981
|
-
* `DatasetDrillRange` declares them `unknown`.
|
|
3982
|
-
*/
|
|
3983
|
-
drillRanges?: Array<Record<string, DatasetDrillRange>>;
|
|
3984
|
-
/** Server-computed marginal aggregates, one entry per requested grouping. */
|
|
3985
|
-
totals?: Array<{ dimensions: string[]; rows: Array<Record<string, unknown>> }>;
|
|
3986
|
-
}> {
|
|
3987
|
-
await this.connect();
|
|
3988
|
-
const base = (this.baseUrl || '').replace(/\/$/, '');
|
|
3989
|
-
const url = `${base}/api/v1/analytics/dataset/query`;
|
|
3990
|
-
// ADR-0037 P3 — draft data preview. Preview mode is URL-keyed by design
|
|
3991
|
-
// (`?preview=draft` flips the whole document, incl. the Live Canvas
|
|
3992
|
-
// iframe), so the adapter reads it straight off the location rather than
|
|
3993
|
-
// threading a React context down through every widget package. When set,
|
|
3994
|
-
// the server overlays the pending seed draft's rows on the dataset query
|
|
3995
|
-
// and resolves draft-overlaid dataset definitions.
|
|
3996
|
-
const previewDrafts =
|
|
3997
|
-
typeof window !== 'undefined' &&
|
|
3998
|
-
new URLSearchParams(window.location.search).get('preview') === 'draft';
|
|
3999
|
-
const requestBody = typeof dataset === 'string'
|
|
4000
|
-
? { datasetName: dataset, selection, ...(previewDrafts ? { previewDrafts: true } : {}) }
|
|
4001
|
-
: { dataset, selection, ...(previewDrafts ? { previewDrafts: true } : {}) };
|
|
4002
|
-
|
|
4003
|
-
const res = await this.fetchImpl(url, {
|
|
4004
|
-
method: 'POST',
|
|
4005
|
-
headers: {
|
|
4006
|
-
'Content-Type': 'application/json',
|
|
4007
|
-
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
|
4008
|
-
},
|
|
4009
|
-
body: JSON.stringify(requestBody),
|
|
4010
|
-
});
|
|
4011
|
-
|
|
4012
|
-
if (!res.ok) {
|
|
4013
|
-
let detail = '';
|
|
4014
|
-
try {
|
|
4015
|
-
const errBody = await res.json();
|
|
4016
|
-
detail = errBody?.message || errBody?.error || JSON.stringify(errBody);
|
|
4017
|
-
} catch { /* non-JSON error body */ }
|
|
4018
|
-
// "The capability isn't installed" is not a stack trace to show an
|
|
4019
|
-
// author (framework#3891): the REST dataset route answers 501
|
|
4020
|
-
// NOT_IMPLEMENTED when no analytics service provides `queryDataset`, and
|
|
4021
|
-
// a host that doesn't mount the route at all answers 404. Both mean the
|
|
4022
|
-
// same thing and both get a message a UI can render verbatim; anything
|
|
4023
|
-
// else (a compile error like "relationship not declared in include") is
|
|
4024
|
-
// a real authoring error and keeps its server detail.
|
|
4025
|
-
if (res.status === 501 || res.status === 404) {
|
|
4026
|
-
throw new AnalyticsNotInstalledError('POST /analytics/dataset/query', detail || undefined);
|
|
4027
|
-
}
|
|
4028
|
-
throw new Error(`Dataset query failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`);
|
|
4029
|
-
}
|
|
4030
|
-
|
|
4031
|
-
const payload = await res.json();
|
|
4032
|
-
// Unwrap the standard `{ success, data }` envelope when present.
|
|
4033
|
-
const data = payload && typeof payload === 'object' && 'success' in payload && 'data' in payload
|
|
4034
|
-
? (payload as { data: unknown }).data
|
|
4035
|
-
: payload;
|
|
4036
|
-
const rows = Array.isArray((data as any)?.rows)
|
|
4037
|
-
? (data as any).rows
|
|
4038
|
-
: (Array.isArray(data) ? (data as any) : []);
|
|
4039
|
-
const fields = Array.isArray((data as any)?.fields) ? (data as any).fields : [];
|
|
4040
|
-
// Drill-through metadata (ADR-0021 D2): the server exposes the dataset's
|
|
4041
|
-
// base object + drillable dimension→field mapping, plus a parallel array of
|
|
4042
|
-
// RAW grouped values (the rows themselves carry display labels), so a host
|
|
4043
|
-
// can build an exact-match filter from a clicked bucket.
|
|
4044
|
-
const object = typeof (data as any)?.object === 'string' ? (data as any).object : undefined;
|
|
4045
|
-
const dimensionFields =
|
|
4046
|
-
(data as any)?.dimensionFields && typeof (data as any).dimensionFields === 'object'
|
|
4047
|
-
? ((data as any).dimensionFields as Record<string, string>)
|
|
4048
|
-
: undefined;
|
|
4049
|
-
const drillRawRows = Array.isArray((data as any)?.drillRawRows) ? (data as any).drillRawRows : undefined;
|
|
4050
|
-
// framework#1752 — the date-range sidecar. Dropping it here (objectui#3813)
|
|
4051
|
-
// made date-bucket drill-through impossible through the only real adapter:
|
|
4052
|
-
// a widget grouped ONLY by a date dimension gets no `dimensionFields` (the
|
|
4053
|
-
// server excludes date dims from the equality drill), so `drillRanges` is
|
|
4054
|
-
// the ONLY thing that can make `canDrill` true — with the key hand-picked
|
|
4055
|
-
// away, the whole drill entry point disappeared, and a mixed grouping
|
|
4056
|
-
// drilled to a superset (every bucket, not the clicked one).
|
|
4057
|
-
const drillRanges = Array.isArray((data as any)?.drillRanges) ? (data as any).drillRanges : undefined;
|
|
4058
|
-
const totals = Array.isArray((data as any)?.totals) ? (data as any).totals : undefined;
|
|
4059
|
-
return { rows, fields, object, dimensionFields, drillRawRows, drillRanges, totals };
|
|
4060
|
-
}
|
|
4061
|
-
|
|
4062
|
-
/** Client-side aggregation fallback */
|
|
4063
|
-
/**
|
|
4064
|
-
* The result column an object-bound `aggregate` projects its value under
|
|
4065
|
-
* (framework#3701, `chartAggregateValueKey` in `@objectstack/spec/ui`): the
|
|
4066
|
-
* raw `field` name — no `sum_`-style decoration, unlike a dataset measure —
|
|
4067
|
-
* or the literal `count` when a count names no field.
|
|
4068
|
-
*/
|
|
4069
|
-
private aggregateValueKey(params: { field?: string; function?: string }): string {
|
|
4070
|
-
return params.field || params.function || 'count';
|
|
4071
|
-
}
|
|
4072
|
-
|
|
4073
|
-
private aggregateClientSide(records: any[], params: { field?: string; function: string; groupBy: string }): any[] {
|
|
4074
|
-
const { field, function: aggFn, groupBy } = params;
|
|
4075
|
-
const valueKey = this.aggregateValueKey(params);
|
|
4076
|
-
const groups: Record<string, any[]> = {};
|
|
4077
|
-
|
|
4078
|
-
for (const record of records) {
|
|
4079
|
-
const key = String(record[groupBy] ?? 'Unknown');
|
|
4080
|
-
if (!groups[key]) groups[key] = [];
|
|
4081
|
-
groups[key].push(record);
|
|
4082
|
-
}
|
|
4083
|
-
|
|
4084
|
-
return Object.entries(groups).map(([key, group]) => {
|
|
4085
|
-
const values = field ? group.map(r => Number(r[field]) || 0) : [];
|
|
4086
|
-
let result: number;
|
|
4087
|
-
|
|
4088
|
-
switch (aggFn) {
|
|
4089
|
-
case 'count': result = group.length; break;
|
|
4090
|
-
case 'avg': result = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; break;
|
|
4091
|
-
case 'min': result = values.length > 0 ? Math.min(...values) : 0; break;
|
|
4092
|
-
case 'max': result = values.length > 0 ? Math.max(...values) : 0; break;
|
|
4093
|
-
case 'sum': default: result = values.reduce((a, b) => a + b, 0); break;
|
|
4094
|
-
}
|
|
4095
|
-
|
|
4096
|
-
return { [groupBy]: key, [valueKey]: result };
|
|
4097
|
-
});
|
|
4098
|
-
}
|
|
4099
|
-
|
|
4100
|
-
/**
|
|
4101
|
-
* Get multiple metadata items from ObjectStack.
|
|
4102
|
-
* Uses v3.0.0 metadata API pattern: getItems for batch retrieval.
|
|
4103
|
-
*/
|
|
4104
|
-
async getItems(category: string, names: string[]): Promise<unknown[]> {
|
|
4105
|
-
await this.connect();
|
|
4106
|
-
|
|
4107
|
-
const results = await Promise.all(
|
|
4108
|
-
names.map(async (name) => {
|
|
4109
|
-
const cacheKey = `${category}:${name}`;
|
|
4110
|
-
return this.metadataCache.get(cacheKey, async () => {
|
|
4111
|
-
const result: any = await this.client.meta.getItem(category, name);
|
|
4112
|
-
if (result && result.item) return result.item;
|
|
4113
|
-
return result;
|
|
4114
|
-
});
|
|
4115
|
-
})
|
|
4116
|
-
);
|
|
4117
|
-
|
|
4118
|
-
return results;
|
|
4119
|
-
}
|
|
4120
|
-
|
|
4121
|
-
/**
|
|
4122
|
-
* Get cached metadata if available, without triggering a fetch.
|
|
4123
|
-
* Uses v3.0.0 metadata API pattern: getCached for synchronous cache access.
|
|
4124
|
-
*/
|
|
4125
|
-
getCached(key: string): unknown | undefined {
|
|
4126
|
-
return this.metadataCache.getCachedSync(key);
|
|
4127
|
-
}
|
|
4128
|
-
|
|
4129
|
-
/**
|
|
4130
|
-
* Get cache statistics for monitoring performance.
|
|
4131
|
-
*/
|
|
4132
|
-
getCacheStats() {
|
|
4133
|
-
return this.metadataCache.getStats();
|
|
4134
|
-
}
|
|
4135
|
-
|
|
4136
|
-
/**
|
|
4137
|
-
* Invalidate metadata cache entries.
|
|
4138
|
-
*
|
|
4139
|
-
* @param key - Optional key to invalidate. If omitted, invalidates all entries.
|
|
4140
|
-
*/
|
|
4141
|
-
invalidateCache(key?: string): void {
|
|
4142
|
-
this.metadataCache.invalidate(key);
|
|
4143
|
-
}
|
|
4144
|
-
|
|
4145
|
-
/**
|
|
4146
|
-
* Clear all cache entries and statistics.
|
|
4147
|
-
*/
|
|
4148
|
-
clearCache(): void {
|
|
4149
|
-
this.metadataCache.clear();
|
|
4150
|
-
}
|
|
4151
|
-
|
|
4152
|
-
/**
|
|
4153
|
-
* Upload a single file to a resource.
|
|
4154
|
-
* Posts the file as multipart/form-data to the ObjectStack server.
|
|
4155
|
-
*
|
|
4156
|
-
* @param resource - The resource/object name to attach the file to
|
|
4157
|
-
* @param file - File object or Blob to upload
|
|
4158
|
-
* @param options - Additional upload options (recordId, fieldName, metadata)
|
|
4159
|
-
* @returns Promise resolving to the upload result (file URL, metadata)
|
|
4160
|
-
*/
|
|
4161
|
-
async uploadFile(
|
|
4162
|
-
resource: string,
|
|
4163
|
-
file: File | Blob,
|
|
4164
|
-
options?: {
|
|
4165
|
-
recordId?: string;
|
|
4166
|
-
fieldName?: string;
|
|
4167
|
-
metadata?: Record<string, unknown>;
|
|
4168
|
-
onProgress?: (percent: number) => void;
|
|
4169
|
-
},
|
|
4170
|
-
): Promise<FileUploadResult> {
|
|
4171
|
-
await this.connect();
|
|
4172
|
-
|
|
4173
|
-
const formData = new FormData();
|
|
4174
|
-
formData.append('file', file);
|
|
4175
|
-
|
|
4176
|
-
if (options?.recordId) {
|
|
4177
|
-
formData.append('recordId', options.recordId);
|
|
4178
|
-
}
|
|
4179
|
-
if (options?.fieldName) {
|
|
4180
|
-
formData.append('fieldName', options.fieldName);
|
|
4181
|
-
}
|
|
4182
|
-
if (options?.metadata) {
|
|
4183
|
-
formData.append('metadata', JSON.stringify(options.metadata));
|
|
4184
|
-
}
|
|
4185
|
-
|
|
4186
|
-
const url = `${this.baseUrl}/api/data/${encodeURIComponent(resource)}/upload`;
|
|
4187
|
-
|
|
4188
|
-
const response = await fetch(url, {
|
|
4189
|
-
method: 'POST',
|
|
4190
|
-
body: formData,
|
|
4191
|
-
headers: {
|
|
4192
|
-
...(this.getAuthHeaders()),
|
|
4193
|
-
},
|
|
4194
|
-
});
|
|
4195
|
-
|
|
4196
|
-
if (!response.ok) {
|
|
4197
|
-
const error = await response.json().catch(() => ({ message: response.statusText }));
|
|
4198
|
-
throw new ObjectStackError(
|
|
4199
|
-
error.message || `Upload failed with status ${response.status}`,
|
|
4200
|
-
'UPLOAD_ERROR',
|
|
4201
|
-
response.status,
|
|
4202
|
-
);
|
|
4203
|
-
}
|
|
4204
|
-
|
|
4205
|
-
return response.json();
|
|
4206
|
-
}
|
|
4207
|
-
|
|
4208
|
-
/**
|
|
4209
|
-
* Upload multiple files to a resource.
|
|
4210
|
-
* Posts all files as a single multipart/form-data request.
|
|
4211
|
-
*
|
|
4212
|
-
* @param resource - The resource/object name to attach the files to
|
|
4213
|
-
* @param files - Array of File objects or Blobs to upload
|
|
4214
|
-
* @param options - Additional upload options
|
|
4215
|
-
* @returns Promise resolving to array of upload results
|
|
4216
|
-
*/
|
|
4217
|
-
async uploadFiles(
|
|
4218
|
-
resource: string,
|
|
4219
|
-
files: (File | Blob)[],
|
|
4220
|
-
options?: {
|
|
4221
|
-
recordId?: string;
|
|
4222
|
-
fieldName?: string;
|
|
4223
|
-
metadata?: Record<string, unknown>;
|
|
4224
|
-
onProgress?: (percent: number) => void;
|
|
4225
|
-
},
|
|
4226
|
-
): Promise<FileUploadResult[]> {
|
|
4227
|
-
await this.connect();
|
|
4228
|
-
|
|
4229
|
-
const formData = new FormData();
|
|
4230
|
-
files.forEach((file, idx) => {
|
|
4231
|
-
formData.append(`files`, file, (file as File).name || `file-${idx}`);
|
|
4232
|
-
});
|
|
4233
|
-
|
|
4234
|
-
if (options?.recordId) {
|
|
4235
|
-
formData.append('recordId', options.recordId);
|
|
4236
|
-
}
|
|
4237
|
-
if (options?.fieldName) {
|
|
4238
|
-
formData.append('fieldName', options.fieldName);
|
|
4239
|
-
}
|
|
4240
|
-
if (options?.metadata) {
|
|
4241
|
-
formData.append('metadata', JSON.stringify(options.metadata));
|
|
4242
|
-
}
|
|
4243
|
-
|
|
4244
|
-
const url = `${this.baseUrl}/api/data/${encodeURIComponent(resource)}/upload`;
|
|
4245
|
-
|
|
4246
|
-
const response = await fetch(url, {
|
|
4247
|
-
method: 'POST',
|
|
4248
|
-
body: formData,
|
|
4249
|
-
headers: {
|
|
4250
|
-
...(this.getAuthHeaders()),
|
|
4251
|
-
},
|
|
4252
|
-
});
|
|
4253
|
-
|
|
4254
|
-
if (!response.ok) {
|
|
4255
|
-
const error = await response.json().catch(() => ({ message: response.statusText }));
|
|
4256
|
-
throw new ObjectStackError(
|
|
4257
|
-
error.message || `Upload failed with status ${response.status}`,
|
|
4258
|
-
'UPLOAD_ERROR',
|
|
4259
|
-
response.status,
|
|
4260
|
-
);
|
|
4261
|
-
}
|
|
4262
|
-
|
|
4263
|
-
return response.json();
|
|
4264
|
-
}
|
|
4265
|
-
|
|
4266
|
-
/**
|
|
4267
|
-
* Cancel (recall) the active pending approval request for a given record.
|
|
4268
|
-
*
|
|
4269
|
-
* Looks up the most recent `sys_approval_request` for the (object, record)
|
|
4270
|
-
* pair whose status is `pending` or `in_approval`, then issues a POST to
|
|
4271
|
-
* `/api/v1/approvals/requests/:id/recall`. The submitter is the only role
|
|
4272
|
-
* permitted to recall on the server — non-submitters will receive a 403.
|
|
4273
|
-
*
|
|
4274
|
-
* On success, the backend mirrors `approval_status = 'recalled'` onto the
|
|
4275
|
-
* source record so the lock badge disappears on next fetch.
|
|
4276
|
-
*/
|
|
4277
|
-
async cancelPendingApproval(
|
|
4278
|
-
objectName: string,
|
|
4279
|
-
recordId: string,
|
|
4280
|
-
): Promise<{ requestId: string; status: string }> {
|
|
4281
|
-
await this.connect();
|
|
4282
|
-
|
|
4283
|
-
// Use the approvals service REST endpoint directly. The generic
|
|
4284
|
-
// `/api/v1/data/sys_approval_request` route applies record-sharing
|
|
4285
|
-
// ACLs that the approvals collection isn't always registered for,
|
|
4286
|
-
// so prefer the cross-cutting `/approvals/requests` endpoint which
|
|
4287
|
-
// is owned by the approvals service itself.
|
|
4288
|
-
const listUrl = `${this.baseUrl}/api/v1/approvals/requests?recordId=${encodeURIComponent(recordId)}&object=${encodeURIComponent(objectName)}`;
|
|
4289
|
-
const listRes = await this.fetchImpl(listUrl, {
|
|
4290
|
-
method: 'GET',
|
|
4291
|
-
headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
|
|
4292
|
-
});
|
|
4293
|
-
if (!listRes.ok) {
|
|
4294
|
-
throw new ObjectStackError(
|
|
4295
|
-
`Failed to look up approval requests (status ${listRes.status})`,
|
|
4296
|
-
'APPROVAL_LOOKUP_FAILED',
|
|
4297
|
-
listRes.status,
|
|
4298
|
-
);
|
|
4299
|
-
}
|
|
4300
|
-
const listBody: any = await listRes.json().catch(() => ({}));
|
|
4301
|
-
const rows: any[] = Array.isArray(listBody) ? listBody : (listBody?.data ?? []);
|
|
4302
|
-
const pending = rows.find(
|
|
4303
|
-
(r) => r?.status === 'pending' || r?.status === 'in_approval',
|
|
4304
|
-
);
|
|
4305
|
-
if (!pending?.id) {
|
|
4306
|
-
throw new ObjectStackError(
|
|
4307
|
-
'No pending approval request found for this record',
|
|
4308
|
-
'NO_PENDING_REQUEST',
|
|
4309
|
-
404,
|
|
4310
|
-
);
|
|
4311
|
-
}
|
|
4312
|
-
|
|
4313
|
-
const url = `${this.baseUrl}/api/v1/approvals/requests/${encodeURIComponent(pending.id)}/recall`;
|
|
4314
|
-
const response = await this.fetchImpl(url, {
|
|
4315
|
-
method: 'POST',
|
|
4316
|
-
headers: {
|
|
4317
|
-
'Content-Type': 'application/json',
|
|
4318
|
-
...this.getAuthHeaders(),
|
|
4319
|
-
},
|
|
4320
|
-
body: JSON.stringify({}),
|
|
4321
|
-
});
|
|
4322
|
-
|
|
4323
|
-
if (!response.ok) {
|
|
4324
|
-
const err: any = await response.json().catch(() => ({}));
|
|
4325
|
-
throw new ObjectStackError(
|
|
4326
|
-
err?.error || err?.message || `Recall failed with status ${response.status}`,
|
|
4327
|
-
err?.code || 'APPROVAL_RECALL_FAILED',
|
|
4328
|
-
response.status,
|
|
4329
|
-
);
|
|
4330
|
-
}
|
|
4331
|
-
const body: any = await response.json().catch(() => ({}));
|
|
4332
|
-
return { requestId: pending.id, status: body?.data?.request?.status ?? 'recalled' };
|
|
4333
|
-
}
|
|
4334
|
-
|
|
4335
|
-
/**
|
|
4336
|
-
* Get authorization headers from the adapter config.
|
|
4337
|
-
*/
|
|
4338
|
-
private getAuthHeaders(): Record<string, string> {
|
|
4339
|
-
const headers: Record<string, string> = {};
|
|
4340
|
-
if (this.token) {
|
|
4341
|
-
headers['Authorization'] = `Bearer ${this.token}`;
|
|
4342
|
-
}
|
|
4343
|
-
return headers;
|
|
4344
|
-
}
|
|
4345
|
-
}
|
|
4346
|
-
|
|
4347
|
-
/**
|
|
4348
|
-
* Factory function to create an ObjectStack data source.
|
|
4349
|
-
*
|
|
4350
|
-
* @example
|
|
4351
|
-
* ```typescript
|
|
4352
|
-
* const dataSource = createObjectStackAdapter({
|
|
4353
|
-
* baseUrl: process.env.API_URL,
|
|
4354
|
-
* token: process.env.API_TOKEN,
|
|
4355
|
-
* cache: { maxSize: 100, ttl: 300000 },
|
|
4356
|
-
* autoReconnect: true,
|
|
4357
|
-
* maxReconnectAttempts: 5
|
|
4358
|
-
* });
|
|
4359
|
-
* ```
|
|
4360
|
-
*/
|
|
4361
|
-
export function createObjectStackAdapter<T = unknown>(config: {
|
|
4362
|
-
baseUrl: string;
|
|
4363
|
-
token?: string;
|
|
4364
|
-
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
4365
|
-
cache?: {
|
|
4366
|
-
maxSize?: number;
|
|
4367
|
-
ttl?: number;
|
|
4368
|
-
};
|
|
4369
|
-
autoReconnect?: boolean;
|
|
4370
|
-
maxReconnectAttempts?: number;
|
|
4371
|
-
reconnectDelay?: number;
|
|
4372
|
-
}): DataSource<T> {
|
|
4373
|
-
return new ObjectStackAdapter<T>(config);
|
|
4374
|
-
}
|
|
4375
|
-
|
|
4376
|
-
// Export error classes for error handling
|
|
4377
|
-
export {
|
|
4378
|
-
ObjectStackError,
|
|
4379
|
-
MetadataNotFoundError,
|
|
4380
|
-
BulkOperationError,
|
|
4381
|
-
ConnectionError,
|
|
4382
|
-
AuthenticationError,
|
|
4383
|
-
DataApiValidationError,
|
|
4384
|
-
createErrorFromResponse,
|
|
4385
|
-
isObjectStackError,
|
|
4386
|
-
isErrorType,
|
|
4387
|
-
} from './errors';
|
|
4388
|
-
|
|
4389
|
-
// Export cache types
|
|
4390
|
-
export type { MetadataCacheStats } from './cache/MetadataCache';
|
|
4391
|
-
|
|
4392
|
-
// v3.0.0 Deep Integration modules
|
|
4393
|
-
// (The cloud deployment/hosting/marketplace surface that used to be re-exported
|
|
4394
|
-
// here was retired by objectui#4152 — it called into a client namespace that
|
|
4395
|
-
// does not exist and fabricated success. `cloud-surface-retired-4152.pin.test.ts`
|
|
4396
|
-
// fails if it returns; that file names the retired symbols, this one must not.)
|
|
4397
|
-
export { validatePluginContract, generateContractManifest } from './contracts';
|
|
4398
|
-
|
|
4399
|
-
// User-scoped persistence adapter (favorites / recent items / …)
|
|
4400
|
-
export { createObjectStackUserStateAdapter } from './userState';
|
|
4401
|
-
export type {
|
|
4402
|
-
ObjectStackUserStateAdapterOptions,
|
|
4403
|
-
UserDataAdapter,
|
|
4404
|
-
} from './userState';
|
|
4405
|
-
export type { PluginContract, PluginExport, PluginAPIContract, ContractValidationResult, ContractValidationError } from './contracts';
|
|
4406
|
-
|
|
4407
|
-
export { IntegrationManager } from './integration';
|
|
4408
|
-
export type { IntegrationConfig, IntegrationTrigger, IntegrationProvider, SlackIntegrationConfig, EmailIntegrationConfig, WebhookIntegrationConfig } from './integration';
|
|
4409
|
-
|
|
4410
|
-
// Metadata API client — read/write protocol metadata via /api/v1/meta/*.
|
|
4411
|
-
// Used by plugin-designer to back the Setup-app Object Manager and Field
|
|
4412
|
-
// Designer surfaces; kept separate from ObjectStackAdapter so callers
|
|
4413
|
-
// can use it without the full data-source surface.
|
|
4414
|
-
export { MetadataClient, readSaveAdvisories } from './metadata-client';
|
|
4415
|
-
export type {
|
|
4416
|
-
RuntimeAuthoringIssue,
|
|
4417
|
-
MetadataSaveAdvisoryEvent,
|
|
4418
|
-
MetadataSaveAdvisoryListener,
|
|
4419
|
-
MetadataClientConfig,
|
|
4420
|
-
MetadataListOptions,
|
|
4421
|
-
MetadataDraftHeader,
|
|
4422
|
-
MetadataClientSaveOptions,
|
|
4423
|
-
MetadataGetOptions,
|
|
4424
|
-
MetadataDeleteOptions,
|
|
4425
|
-
MetadataHistoryOptions,
|
|
4426
|
-
MetadataError,
|
|
4427
|
-
MetadataValidationIssue,
|
|
4428
|
-
MetadataLayered,
|
|
4429
|
-
MetadataReference,
|
|
4430
|
-
MetadataDiagnostics,
|
|
4431
|
-
MetadataDiagnosticsOptions,
|
|
4432
|
-
MetadataDiagnosticsEntry,
|
|
4433
|
-
MetadataDiagnosticsSummary,
|
|
4434
|
-
MetadataAuditEntry,
|
|
4435
|
-
MetadataAuditResponse,
|
|
4436
|
-
} from './metadata-client';
|
|
4437
|
-
|
|
4438
|
-
export { SecurityManager } from './security';
|
|
4439
|
-
export type { SecurityManagerPolicy, CSPConfig, AuditLogConfig, AuditEventType, DataMaskingConfig, DataMaskingRule, AuditLogEntry } from './security';
|
|
4440
|
-
|
|
4441
|
-
export { createDefaultCanvasConfig, snapToGrid, calculateAutoLayout } from './studio';
|
|
4442
|
-
export type { StudioCanvasConfig, StudioPropertyEditor, StudioThemeBuilderConfig, StudioColorPalette, StudioTypographyPreset, StudioShadowPreset } from './studio';
|