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