@object-ui/data-objectstack 17.3.0 → 17.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +669 -0
- package/README.md +3 -11
- package/dist/index.cjs +1258 -767
- package/dist/index.d.cts +1367 -906
- package/dist/index.d.ts +1367 -906
- package/dist/index.js +1210 -726
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +5 -5
- package/src/apiAccessDenied.test.ts +233 -0
- package/src/appAccessProbe.test.ts +147 -0
- package/src/cloud-surface-retired-4152.pin.test.ts +171 -0
- package/src/deleteView.homes.test.ts +389 -0
- package/src/deleteViewContract.types.test.ts +246 -0
- package/src/filter-operator-ast-parity.test.ts +75 -11
- package/src/httpFailureLogging.test.ts +109 -0
- package/src/index.ts +879 -95
- package/src/listViewOverrides.test.ts +241 -0
- package/src/metadata-client.get-envelope.test.ts +171 -0
- package/src/metadata-client.saveAdvisories.test.ts +226 -0
- package/src/metadata-client.ts +208 -11
- package/src/onMutation.test.ts +29 -2
- package/src/onSaveAdvisory.test.ts +311 -0
- package/src/queryDataset.test.ts +411 -0
- package/src/spec-symbol-batch6.test.ts +21 -1
- package/src/updateView.draft.test.ts +242 -0
- package/src/v3-compat.test.ts +10 -21
- package/src/viewCacheInvalidation.pin.test.ts +260 -0
- package/src/viewInvalidationSeam.pin.test.ts +162 -0
- package/src/cloud.ts +0 -109
package/src/index.ts
CHANGED
|
@@ -8,8 +8,23 @@
|
|
|
8
8
|
|
|
9
9
|
import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client';
|
|
10
10
|
import type { DroppedFieldsEvent } from '@objectstack/spec/data';
|
|
11
|
+
// #4237 — the metadata save door's advisory reader, shared with `MetadataClient`
|
|
12
|
+
// rather than forked. ONE reader, two call sites: the other client class calls it
|
|
13
|
+
// from `MetadataClient.save` (#4133/#4236), this one from the interceptor below.
|
|
14
|
+
import {
|
|
15
|
+
readSaveAdvisories,
|
|
16
|
+
type MetadataSaveAdvisoryEvent,
|
|
17
|
+
type MetadataSaveAdvisoryListener,
|
|
18
|
+
} from './metadata-client';
|
|
19
|
+
import type { AnalyticsResult, DatasetSelection } from '@objectstack/spec/contracts';
|
|
11
20
|
import type {
|
|
12
21
|
DataSource,
|
|
22
|
+
// #4564 — the canonical `deleteView` receipt shapes, declared beside the
|
|
23
|
+
// `DataSource` interface they belong to. Imported for the local uses below
|
|
24
|
+
// and re-exported under the same names further down, so this package's
|
|
25
|
+
// existing importers are untouched by the move.
|
|
26
|
+
DeleteViewResult,
|
|
27
|
+
ViewHomeDeleteOutcome,
|
|
13
28
|
BatchTransactionOperation,
|
|
14
29
|
DataSourceMutationEvent,
|
|
15
30
|
QueryParams,
|
|
@@ -27,11 +42,12 @@ import type {
|
|
|
27
42
|
ImportJobUndoResult,
|
|
28
43
|
ListImportJobsOptions,
|
|
29
44
|
} from '@object-ui/types';
|
|
30
|
-
import { errorCodeIsAnyOf } from '@object-ui/types';
|
|
45
|
+
import { errorCodeIs, errorCodeIsAnyOf } from '@object-ui/types';
|
|
31
46
|
import {
|
|
32
47
|
convertFiltersToAST,
|
|
33
48
|
emulateBatchTransaction,
|
|
34
49
|
normalizeSchemaReferenceKeys,
|
|
50
|
+
type DatasetDrillRange,
|
|
35
51
|
} from '@object-ui/core';
|
|
36
52
|
import { MetadataCache } from './cache/MetadataCache';
|
|
37
53
|
import { MetadataClient } from './metadata-client';
|
|
@@ -439,6 +455,95 @@ export function is404Error(error: unknown): boolean {
|
|
|
439
455
|
return errorCodeIsAnyOf({ code }, ['OBJECT_NOT_FOUND', 'RECORD_NOT_FOUND']);
|
|
440
456
|
}
|
|
441
457
|
|
|
458
|
+
/**
|
|
459
|
+
* The two denials the server derives from an object's `enable` block —
|
|
460
|
+
* `apiAccessDenialFromEnable` (objectstack `packages/rest/src/rest-server.ts`).
|
|
461
|
+
*
|
|
462
|
+
* - `OBJECT_API_DISABLED` (404) — `enable.apiEnabled: false`; the object is
|
|
463
|
+
* not exposed over the data API at all.
|
|
464
|
+
* - `OBJECT_API_METHOD_NOT_ALLOWED` (405) — the operation is absent from the
|
|
465
|
+
* `enable.apiMethods` whitelist.
|
|
466
|
+
*
|
|
467
|
+
* Both are **pure functions of the object's metadata**: no user, no permission,
|
|
468
|
+
* no context, no request body. So neither is transient and neither is
|
|
469
|
+
* per-user — when one happens it is a permanent property of that object, and
|
|
470
|
+
* every retry of every persona gets the identical answer.
|
|
471
|
+
*
|
|
472
|
+
* That is exactly why they must not be degraded into "no data". A 404 from a
|
|
473
|
+
* missing collection means *this backend doesn't have that table*, which the
|
|
474
|
+
* optional-collection probes below legitimately read as "feature unavailable";
|
|
475
|
+
* a 404 from `OBJECT_API_DISABLED` means *this page can never work*. Answering
|
|
476
|
+
* the second with an empty result set renders "you have no records" over a
|
|
477
|
+
* surface that is not allowed to have any (objectui#4408 — it also hid the
|
|
478
|
+
* upstream defect objectstack#7544 for its entire life, because a merely
|
|
479
|
+
* unpopulated page invites nobody to click through).
|
|
480
|
+
*/
|
|
481
|
+
export const API_ACCESS_DENIED_CODES = [
|
|
482
|
+
'OBJECT_API_DISABLED',
|
|
483
|
+
'OBJECT_API_METHOD_NOT_ALLOWED',
|
|
484
|
+
] as const;
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* True when `error` is an `enable`-block API denial (see
|
|
488
|
+
* {@link API_ACCESS_DENIED_CODES}).
|
|
489
|
+
*
|
|
490
|
+
* Discriminates on the ADR-0112 `code`, never on the status: 404 alone cannot
|
|
491
|
+
* separate a disabled object from a missing collection, and 405 alone cannot
|
|
492
|
+
* separate a withheld method from any other method rejection. The code survives
|
|
493
|
+
* the transport — `@objectstack/client`'s fetch wrapper stamps `error.code`
|
|
494
|
+
* from the response envelope, and both spellings are declared members of the
|
|
495
|
+
* spec's `StandardErrorCode` — so no heuristic on status is needed or wanted.
|
|
496
|
+
*/
|
|
497
|
+
export function isApiAccessDeniedError(error: unknown): boolean {
|
|
498
|
+
return errorCodeIsAnyOf(error, API_ACCESS_DENIED_CODES);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* What the by-name meta app route said about THIS session's access to an app
|
|
503
|
+
* (objectui#4252 / objectstack#8013).
|
|
504
|
+
*
|
|
505
|
+
* - `granted` — the route served the app document.
|
|
506
|
+
* - `denied` — the app EXISTS and the session lacks its `requiredPermissions`.
|
|
507
|
+
* The only verdict a caller may render as an authorization refusal.
|
|
508
|
+
* - `unknown` — anything else: an absent app, an unpublished one, an app
|
|
509
|
+
* withheld by an absent optional service, an unreachable server, an adapter
|
|
510
|
+
* that cannot ask. All of these are cases where the server declined to say
|
|
511
|
+
* that a permission of the caller's is missing, so no caller may claim it.
|
|
512
|
+
*
|
|
513
|
+
* Three values rather than a boolean because the third is not a shade of the
|
|
514
|
+
* other two: "the app is missing" and "I could not find out" both have to leave
|
|
515
|
+
* the caller's existing copy alone, and collapsing them into `false` invites a
|
|
516
|
+
* consumer to read a failed probe as a positive absence.
|
|
517
|
+
*/
|
|
518
|
+
export type AppAccessVerdict = 'granted' | 'denied' | 'unknown';
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The ADR-0112 standard catalog code the by-name meta app route answers with
|
|
522
|
+
* when an app exists and the session lacks its `requiredPermissions`
|
|
523
|
+
* (objectstack#8013, `sendError(res, 403, 'PERMISSION_DENIED', …)` in
|
|
524
|
+
* `packages/rest/src/rest-server.ts`).
|
|
525
|
+
*
|
|
526
|
+
* Deliberately NOT a member of {@link API_ACCESS_DENIED_CODES}: those two are
|
|
527
|
+
* pure functions of an object's `enable` metadata — permanent, identical for
|
|
528
|
+
* every persona — whereas this one is a statement about the CALLER, and the same
|
|
529
|
+
* request by a different session succeeds. Same word "denied", different
|
|
530
|
+
* question, so a consumer that wants one must never match the other.
|
|
531
|
+
*/
|
|
532
|
+
export const APP_PERMISSION_DENIED_CODE = 'PERMISSION_DENIED';
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* True when `error` is the by-name app route's permission denial.
|
|
536
|
+
*
|
|
537
|
+
* Discriminates on the ADR-0112 `code`, never on the status (objectui#4408): the
|
|
538
|
+
* route answers 403 for this and 404 for absence today, but a status is a
|
|
539
|
+
* transport fact many conditions share, while the code is the contract. The
|
|
540
|
+
* console's whole reason to call this is to tell two REFUSALS apart, and both
|
|
541
|
+
* are errors.
|
|
542
|
+
*/
|
|
543
|
+
export function isAppPermissionDeniedError(error: unknown): boolean {
|
|
544
|
+
return errorCodeIs(error, APP_PERMISSION_DENIED_CODE);
|
|
545
|
+
}
|
|
546
|
+
|
|
442
547
|
/**
|
|
443
548
|
* Thrown when the deployment has no analytics capability installed
|
|
444
549
|
* (framework#3891 / #4019).
|
|
@@ -640,17 +745,78 @@ export function normaliseClientError(error: unknown): unknown {
|
|
|
640
745
|
}
|
|
641
746
|
|
|
642
747
|
/**
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
748
|
+
* Fold an @objectstack/client HTTP-failure `meta` bag into the log MESSAGE.
|
|
749
|
+
*
|
|
750
|
+
* The client already hands us everything worth knowing —
|
|
751
|
+
* `logger.error("HTTP request failed", undefined, { method, url, status, error })`
|
|
752
|
+
* — but it hands it as the THIRD argument. Everything that flattens a console
|
|
753
|
+
* record to text (a headless/CDP console capture, a log shipper, a copied
|
|
754
|
+
* DevTools line) keeps only the message and renders the rest as `[object
|
|
755
|
+
* Object]` / `Object`, so a wall of failures carried no method, no URL and no
|
|
756
|
+
* status: the reporter of objectui#4042 had to diff the network panel by hand
|
|
757
|
+
* to find out that 30 red lines were all one benign pre-login burst.
|
|
758
|
+
*
|
|
759
|
+
* So the identifying fields go into the string itself, and the structured bag
|
|
760
|
+
* is STILL passed alongside for DevTools to expand — text for the flatteners,
|
|
761
|
+
* object for the inspectors, neither at the other's expense.
|
|
762
|
+
*
|
|
763
|
+
* Exported for tests. Returns `null` when `meta` carries none of the three
|
|
764
|
+
* fields, so callers keep the original message rather than printing a husk.
|
|
765
|
+
*/
|
|
766
|
+
export function formatHttpFailureMessage(
|
|
767
|
+
message: string,
|
|
768
|
+
meta?: Record<string, any>,
|
|
769
|
+
): string | null {
|
|
770
|
+
if (!meta || typeof meta !== 'object') return null;
|
|
771
|
+
const method = typeof meta.method === 'string' && meta.method ? meta.method : undefined;
|
|
772
|
+
const url = typeof meta.url === 'string' && meta.url ? meta.url : undefined;
|
|
773
|
+
const status =
|
|
774
|
+
typeof meta.status === 'number'
|
|
775
|
+
? meta.status
|
|
776
|
+
: typeof meta.statusCode === 'number'
|
|
777
|
+
? meta.statusCode
|
|
778
|
+
: undefined;
|
|
779
|
+
if (!method && !url && status === undefined) return null;
|
|
780
|
+
|
|
781
|
+
// `code` is the ADR-0112 semantic error code. The client puts the parsed
|
|
782
|
+
// body under `meta.error`; some call sites hoist the code to the top level.
|
|
783
|
+
const errBody = meta.error && typeof meta.error === 'object' ? meta.error : undefined;
|
|
784
|
+
const rawCode =
|
|
785
|
+
(typeof meta.code === 'string' && meta.code) ||
|
|
786
|
+
(errBody && typeof (errBody as Record<string, unknown>).code === 'string'
|
|
787
|
+
? ((errBody as Record<string, string>).code)
|
|
788
|
+
: undefined);
|
|
789
|
+
|
|
790
|
+
const parts = [method ?? 'GET', url ?? '<unknown url>'];
|
|
791
|
+
parts.push(`-> ${status ?? 'no status'}`);
|
|
792
|
+
if (rawCode) parts.push(`[${rawCode}]`);
|
|
793
|
+
return `${message}: ${parts.join(' ')}`;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Build a Logger compatible with @objectstack/client that (a) spells every
|
|
798
|
+
* request failure out in the message — see {@link formatHttpFailureMessage} —
|
|
799
|
+
* and (b) demotes expected 404 noise to console.debug. The client logs every
|
|
800
|
+
* non-2xx response with
|
|
801
|
+
* `logger.error("HTTP request failed", undefined, { method, url, status, error })`,
|
|
802
|
+
* but 404s on optional collections (sys_presence, sys_activity, …) are part of
|
|
647
803
|
* normal degraded operation when those plugins aren't installed on the
|
|
648
804
|
* server — they should not surface as errors in the browser DevTools.
|
|
649
805
|
*
|
|
806
|
+
* NOTE the asymmetry, and keep it: 404-on-an-optional-collection is demoted
|
|
807
|
+
* because it is an EXPECTED outcome of a request we still mean to make. No
|
|
808
|
+
* other status is demoted — a 401 that survives the console's session gate
|
|
809
|
+
* (objectui#4042: a mid-session expiry, say) is a real event and must stay a
|
|
810
|
+
* visible, fully-identified error. The cure for doomed requests is not issuing
|
|
811
|
+
* them, never hiding them once issued.
|
|
812
|
+
*
|
|
650
813
|
* Returned object is loosely typed because the spec's Logger interface lives
|
|
651
814
|
* in a transitive package; using `any` keeps us decoupled.
|
|
815
|
+
*
|
|
816
|
+
* Exported so the console's log contract is testable, and so an app wiring its
|
|
817
|
+
* own `ObjectStackClient` gets the same identified failures.
|
|
652
818
|
*/
|
|
653
|
-
function createQuietHttpLogger(): any {
|
|
819
|
+
export function createQuietHttpLogger(): any {
|
|
654
820
|
const isExpected404 = (meta?: Record<string, any>): boolean => {
|
|
655
821
|
if (!meta || typeof meta !== 'object') return false;
|
|
656
822
|
if (meta.status === 404 || meta.statusCode === 404) return true;
|
|
@@ -670,13 +836,16 @@ function createQuietHttpLogger(): any {
|
|
|
670
836
|
console.warn(message, meta ?? ''),
|
|
671
837
|
error: (message: string, error?: Error, meta?: Record<string, any>) => {
|
|
672
838
|
if (isExpected404(meta)) {
|
|
673
|
-
console.debug(
|
|
839
|
+
console.debug(
|
|
840
|
+
`[ObjectStack] ${formatHttpFailureMessage(message, meta) ?? message} (suppressed expected 404)`,
|
|
841
|
+
meta,
|
|
842
|
+
);
|
|
674
843
|
return;
|
|
675
844
|
}
|
|
676
|
-
console.error(message, error ?? '', meta ?? '');
|
|
845
|
+
console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? '', meta ?? '');
|
|
677
846
|
},
|
|
678
847
|
fatal: (message: string, error?: Error, meta?: Record<string, any>) =>
|
|
679
|
-
console.error(message, error ?? '', meta ?? ''),
|
|
848
|
+
console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? '', meta ?? ''),
|
|
680
849
|
log: (message: string, ...args: any[]) => console.log(message, ...args),
|
|
681
850
|
child: () => logger,
|
|
682
851
|
withTrace: () => logger,
|
|
@@ -830,6 +999,132 @@ function withoutNoOpDrops(
|
|
|
830
999
|
return out;
|
|
831
1000
|
}
|
|
832
1001
|
|
|
1002
|
+
/**
|
|
1003
|
+
* Resolve which object a `type='view'` metadata item belongs to.
|
|
1004
|
+
*
|
|
1005
|
+
* The metadata index is name-only, not field-typed: `GET /api/v1/meta/view`
|
|
1006
|
+
* accepts `?package=` and `?preview=draft` and nothing else (measured on
|
|
1007
|
+
* framework `packages/rest/src/rest-server.ts` — the `GET /meta/:type`
|
|
1008
|
+
* handler — and on `client.meta.getItems(type, { packageId })`). So every
|
|
1009
|
+
* reader of the view namespace enumerates `type='view'` once and narrows to
|
|
1010
|
+
* one object HERE, client-side.
|
|
1011
|
+
*
|
|
1012
|
+
* ONE spelling, one place, deliberately: {@link ObjectStackAdapter.listViews}
|
|
1013
|
+
* and {@link ObjectStackAdapter.listViewOverrides} read the same rows out of
|
|
1014
|
+
* the same namespace, and two private copies of "which object is this?" is a
|
|
1015
|
+
* drift waiting to happen — the switcher showing a view whose override the
|
|
1016
|
+
* grid cannot find, or the reverse.
|
|
1017
|
+
*
|
|
1018
|
+
* `object` is the identity field the write path stamps (and that the
|
|
1019
|
+
* framework's overlay heals onto identity-less personalization rows —
|
|
1020
|
+
* objectstack#2555); `data.object` is the config's data-provider target and
|
|
1021
|
+
* `objectName` the legacy artifact spelling.
|
|
1022
|
+
*
|
|
1023
|
+
* **Exported** since objectui#4373, for the same one-spelling reason: a writer
|
|
1024
|
+
* outside this module that holds a view BODY but not its object name (app-shell's
|
|
1025
|
+
* `MetadataService`) needs the object to name the keys
|
|
1026
|
+
* {@link ObjectStackAdapter.invalidateViewKeys} drops, and a fourth private copy
|
|
1027
|
+
* of "which object is this?" is exactly the drift this accessor exists to
|
|
1028
|
+
* prevent. Identity only — it names no cache key.
|
|
1029
|
+
*/
|
|
1030
|
+
export function viewItemObjectName(item: any): string | undefined {
|
|
1031
|
+
// Handle both bare view spec and `{list: {...}}` artifact wrapper
|
|
1032
|
+
const spec = item?.list ?? item;
|
|
1033
|
+
return spec?.data?.object ?? spec?.object ?? spec?.objectName;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Unwrap a `?state=draft` view read into its bare body, or `null` when there
|
|
1038
|
+
* is nothing pending (#4139).
|
|
1039
|
+
*
|
|
1040
|
+
* The framework answers EVERY single-item read — draft or published — in a
|
|
1041
|
+
* `{type, name, item}` envelope. `MetadataClient.get()` unwraps it at the
|
|
1042
|
+
* client boundary (objectui#4271) while `getDraft()` deliberately hands the
|
|
1043
|
+
* envelope back, so this helper stays tolerant of both: the call below reaches
|
|
1044
|
+
* it through `get()` and therefore already holds the body, and the passthrough
|
|
1045
|
+
* limb keeps it correct for an envelope arriving by any other route. An empty
|
|
1046
|
+
* body is normalized to `null` so the caller's "is this view draft-backed?"
|
|
1047
|
+
* test is a plain truthiness check. Mirrors app-shell's `unwrapDraftBody`
|
|
1048
|
+
* (ADR-0034 seam); the two live apart because the seam sits above this
|
|
1049
|
+
* adapter, not beside it.
|
|
1050
|
+
*/
|
|
1051
|
+
function unwrapViewDraft(resp: unknown): Record<string, any> | null {
|
|
1052
|
+
if (!resp || typeof resp !== 'object') return null;
|
|
1053
|
+
const env = resp as Record<string, any>;
|
|
1054
|
+
const body = 'item' in env ? env.item : env;
|
|
1055
|
+
if (!body || typeof body !== 'object') return null;
|
|
1056
|
+
// Same `{list: {...}}` artifact wrapper the published read unwraps.
|
|
1057
|
+
const spec = body.list ?? body;
|
|
1058
|
+
if (!spec || typeof spec !== 'object') return null;
|
|
1059
|
+
return Object.keys(spec).length > 0 ? (spec as Record<string, any>) : null;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* `deleteView`'s receipt shapes, RE-EXPORTED from `@object-ui/types` (#4564).
|
|
1064
|
+
*
|
|
1065
|
+
* #4479 first declared both here, because this adapter is where the widened
|
|
1066
|
+
* receipt is produced. That left the shared `DataSource.deleteView?` unable to
|
|
1067
|
+
* describe it: the dependency runs this package -> `@object-ui/types` and never
|
|
1068
|
+
* the other way, so the interface kept the narrow `{ deleted: boolean }` and a
|
|
1069
|
+
* consumer reaching this adapter THROUGH `DataSource` was handed a type with
|
|
1070
|
+
* the per-home outcomes already discarded. Nothing failed to compile — a wider
|
|
1071
|
+
* return is assignable to a narrower declaration — which is precisely why the
|
|
1072
|
+
* gap was silent until #4564 measured it.
|
|
1073
|
+
*
|
|
1074
|
+
* So the canonical declarations now live beside the interface they belong to,
|
|
1075
|
+
* in `packages/types/src/data.ts`, and this package re-exports them under the
|
|
1076
|
+
* SAME names: every importer PR #4562 left pointing at
|
|
1077
|
+
* `@object-ui/data-objectstack` keeps compiling, and now gets the very type the
|
|
1078
|
+
* shared contract speaks rather than a structural twin of it. Pinned by
|
|
1079
|
+
* `deleteViewContract.types.test.ts`, which asserts type IDENTITY — mutual
|
|
1080
|
+
* assignability would have been satisfied by a copy that was already drifting.
|
|
1081
|
+
*/
|
|
1082
|
+
export type { DeleteViewResult, ViewHomeDeleteOutcome } from '@object-ui/types';
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Read one delete receipt into a {@link ViewHomeDeleteOutcome}.
|
|
1086
|
+
*
|
|
1087
|
+
* The `deleted ?? reset ?? true` ladder is carried over verbatim from the
|
|
1088
|
+
* single-call `deleteView` this replaced, so a server that answers a shape
|
|
1089
|
+
* with neither key is read exactly as it was before (#4479): the framework
|
|
1090
|
+
* sends `reset`, the SDK's typed metadata shape names `deleted`, and the
|
|
1091
|
+
* final `true` is the "it answered 2xx and said nothing" default.
|
|
1092
|
+
*/
|
|
1093
|
+
function readViewDeleteReceipt(result: unknown): ViewHomeDeleteOutcome {
|
|
1094
|
+
const r = (result ?? undefined) as Record<string, any> | undefined;
|
|
1095
|
+
return {
|
|
1096
|
+
removed: !!(r?.deleted ?? r?.reset ?? true),
|
|
1097
|
+
...(typeof r?.reset === 'boolean' ? { reset: r.reset } : {}),
|
|
1098
|
+
...(typeof r?.message === 'string' ? { message: r.message } : {}),
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Merge a partial view patch onto the CURRENT view document.
|
|
1104
|
+
*
|
|
1105
|
+
* ADR-0005 overlay rows store the *full* view document, so a partial update is
|
|
1106
|
+
* a read-merge-write cycle and the merge must start from real current state —
|
|
1107
|
+
* merging onto `{}` yields a `{label, name, object}` fragment the server
|
|
1108
|
+
* rejects (422), which is exactly how a rename used to be lost (#4139).
|
|
1109
|
+
*
|
|
1110
|
+
* `name` is forced to the URL segment so the row key and `body.name` agree
|
|
1111
|
+
* (#2767 P1), and `object` falls back through the two spellings a stored view
|
|
1112
|
+
* may carry before defaulting to the caller's object.
|
|
1113
|
+
*/
|
|
1114
|
+
function mergeViewPatch(
|
|
1115
|
+
current: Record<string, any>,
|
|
1116
|
+
partial: Record<string, any>,
|
|
1117
|
+
viewName: string,
|
|
1118
|
+
objectName: string,
|
|
1119
|
+
): Record<string, any> {
|
|
1120
|
+
return {
|
|
1121
|
+
...current,
|
|
1122
|
+
...partial,
|
|
1123
|
+
name: viewName,
|
|
1124
|
+
object: current?.object || current?.data?.object || objectName,
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
|
|
833
1128
|
/**
|
|
834
1129
|
* ObjectStack Data Source Adapter
|
|
835
1130
|
*
|
|
@@ -912,6 +1207,13 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
912
1207
|
// shell can surface a toast instead of the strip passing silently.
|
|
913
1208
|
private writeWarningListeners = new Set<WriteWarningListener>();
|
|
914
1209
|
|
|
1210
|
+
// Subscribers registered via onSaveAdvisory(). Emitted after a metadata save
|
|
1211
|
+
// through THIS adapter's `ObjectStackClient` whose 200 carried a non-empty
|
|
1212
|
+
// `advisories` array (#4237; backend objectstack#7435). Sibling of the set
|
|
1213
|
+
// above in every respect except which door produced the event: that one is
|
|
1214
|
+
// record CRUD, this one is the metadata save door.
|
|
1215
|
+
private saveAdvisoryListeners = new Set<MetadataSaveAdvisoryListener>();
|
|
1216
|
+
|
|
915
1217
|
constructor(config: {
|
|
916
1218
|
baseUrl: string;
|
|
917
1219
|
token?: string;
|
|
@@ -929,6 +1231,9 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
929
1231
|
// debug() so they don't pollute the browser console. Other log levels are
|
|
930
1232
|
// forwarded to the standard console.
|
|
931
1233
|
this.client = new ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
|
|
1234
|
+
// #4237 — one emitter for every metadata save this adapter's client makes,
|
|
1235
|
+
// installed the moment the client exists so no save can precede it.
|
|
1236
|
+
this.installSaveAdvisoryInterceptor();
|
|
932
1237
|
this.metadataCache = new MetadataCache(config.cache);
|
|
933
1238
|
this.autoReconnect = config.autoReconnect ?? true;
|
|
934
1239
|
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3;
|
|
@@ -1144,7 +1449,14 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1144
1449
|
const result: unknown = await this.client.data.find<T>(resource, queryOptions);
|
|
1145
1450
|
return this.normalizeQueryResult(result, params);
|
|
1146
1451
|
} catch (err) {
|
|
1147
|
-
|
|
1452
|
+
// An `enable`-block denial is NOT a missing collection. The object
|
|
1453
|
+
// exists and the server is deliberately refusing to expose it, forever
|
|
1454
|
+
// and for everyone — degrading that to an empty result set tells the
|
|
1455
|
+
// user "you have no records" about a page that can never hold any
|
|
1456
|
+
// (objectui#4408). Rethrow so the surface can say what happened; the
|
|
1457
|
+
// `missingResources` memo must not absorb it either, or the very first
|
|
1458
|
+
// denial would silently pin every later call to empty.
|
|
1459
|
+
if (!isApiAccessDeniedError(err) && is404Error(err)) {
|
|
1148
1460
|
// Mark the resource so subsequent calls don't repeat the 404.
|
|
1149
1461
|
this.missingResources.add(resource);
|
|
1150
1462
|
return { data: [], total: 0 } as QueryResult<T>;
|
|
@@ -1444,6 +1756,122 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1444
1756
|
};
|
|
1445
1757
|
}
|
|
1446
1758
|
|
|
1759
|
+
/**
|
|
1760
|
+
* Subscribe to metadata save-advisory events — the runtime authoring gate's
|
|
1761
|
+
* advisory findings on a save that SUCCEEDED (#4237; backend
|
|
1762
|
+
* objectstack#7435). Returns an unsubscribe function.
|
|
1763
|
+
*
|
|
1764
|
+
* Deliberately the same seam as {@link onWriteWarning} (#3431/#3455), which
|
|
1765
|
+
* is what {@link MetadataSaveAdvisoryEvent}'s own declaration already said it
|
|
1766
|
+
* was modelled on. It is a SIBLING of that channel rather than a second
|
|
1767
|
+
* payload pushed down it: `WriteWarningEvent` is a closed shape whose
|
|
1768
|
+
* `droppedFields` is required and means "fields the write legally stripped",
|
|
1769
|
+
* so carrying advisories on it would either force every existing
|
|
1770
|
+
* `onWriteWarning` consumer to grow a branch or make the event lie about what
|
|
1771
|
+
* happened. The seam's SHAPE is what is reused here — a long-lived instance
|
|
1772
|
+
* with a `subscribe → unsubscribe` registration that `AdapterProvider` wires
|
|
1773
|
+
* once — not its event type.
|
|
1774
|
+
*
|
|
1775
|
+
* Why here and not on the config, which is how the other client class does it
|
|
1776
|
+
* (#4133/#4236): `MetadataClient` is minted per component by
|
|
1777
|
+
* `useMetadataClient`, so it has no instance to subscribe to and its sink
|
|
1778
|
+
* rides the factory. `ObjectStackAdapter` is the opposite — one long-lived
|
|
1779
|
+
* instance per app, already carrying this exact subscription pattern.
|
|
1780
|
+
*/
|
|
1781
|
+
onSaveAdvisory(callback: MetadataSaveAdvisoryListener): () => void {
|
|
1782
|
+
this.saveAdvisoryListeners.add(callback);
|
|
1783
|
+
return () => {
|
|
1784
|
+
this.saveAdvisoryListeners.delete(callback);
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* Notify all save-advisory subscribers. Isolated exactly like
|
|
1790
|
+
* {@link emitWriteWarning}: a throwing listener must neither break the save
|
|
1791
|
+
* nor starve the others.
|
|
1792
|
+
*/
|
|
1793
|
+
private emitSaveAdvisory(event: MetadataSaveAdvisoryEvent): void {
|
|
1794
|
+
for (const listener of this.saveAdvisoryListeners) {
|
|
1795
|
+
try {
|
|
1796
|
+
listener(event);
|
|
1797
|
+
} catch (err) {
|
|
1798
|
+
console.warn('ObjectStackAdapter: save-advisory listener error', err);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* Install the ONE emitter for the metadata save door (#4237).
|
|
1805
|
+
*
|
|
1806
|
+
* ## Why this seam, and what it covers
|
|
1807
|
+
*
|
|
1808
|
+
* `ObjectStackClient.meta.saveItem` is the second client class that writes
|
|
1809
|
+
* through `PUT /api/v1/meta/:type/:name`, and every one of its callers reaches
|
|
1810
|
+
* it through an adapter this class constructed — the four inside this file
|
|
1811
|
+
* (`updateViewConfig`, the two view paths, `updateDashboard`) via
|
|
1812
|
+
* `this.client`, and every caller outside it via {@link getClient}, which
|
|
1813
|
+
* hands back this same instance: `MetadataService` (app-shell, five saves),
|
|
1814
|
+
* `useNavigationSync`, and plugin-designer's Create/EditAppPage. Wrapping the
|
|
1815
|
+
* method once here therefore covers all of them WITHOUT a per-site edit, which
|
|
1816
|
+
* is the whole point — a toast copied into a dozen call sites is the shape
|
|
1817
|
+
* #4133 rejected for the other client class and it is no better here.
|
|
1818
|
+
*
|
|
1819
|
+
* `meta` is an own, writable property assigned per instance in the SDK's
|
|
1820
|
+
* constructor (`this.meta = { … }`), and the client this adapter builds is
|
|
1821
|
+
* never shared, so the wrap is bounded to an object this adapter owns for its
|
|
1822
|
+
* whole lifetime. It is not a prototype or global patch.
|
|
1823
|
+
*
|
|
1824
|
+
* ## Response shape — measured, not assumed
|
|
1825
|
+
*
|
|
1826
|
+
* The two client classes' envelopes coincide at the top level, which is what
|
|
1827
|
+
* makes `readSaveAdvisories` reusable unchanged across both. `SaveMetaItem-
|
|
1828
|
+
* ResponseSchema` puts `advisories` at the body's top level next to
|
|
1829
|
+
* `success` / `version` / `seq` / `state`, and the SDK's `unwrapResponse`
|
|
1830
|
+
* strips its `{ success, data }` envelope only when the body actually HAS a
|
|
1831
|
+
* `data` key — this body does not, so it is returned verbatim. So the same
|
|
1832
|
+
* reader that `MetadataClient.save` uses reads this response correctly, and
|
|
1833
|
+
* the pins in `onSaveAdvisory.test.ts` drive a real SDK client through a fake
|
|
1834
|
+
* `fetch` rather than stubbing `meta`, so that continues to be measured.
|
|
1835
|
+
*
|
|
1836
|
+
* ## Draft-door honesty (D1)
|
|
1837
|
+
*
|
|
1838
|
+
* Drafts are NEVER gated: the framework returns at its D1 early-return
|
|
1839
|
+
* (`if (args.state !== 'active') return null`) before running a rule, so a
|
|
1840
|
+
* draft save produces no findings to withhold. This client class has no draft
|
|
1841
|
+
* door at all to worry about — the SDK's `saveItem(type, name, item)` takes no
|
|
1842
|
+
* mode and always writes the active door, which is exactly why the gate DOES
|
|
1843
|
+
* run for its callers. `mode` on the emitted event is therefore derived from
|
|
1844
|
+
* the response's own `state` rather than from a request-side flag that does
|
|
1845
|
+
* not exist here: `'draft'` when the server says the row landed as a draft,
|
|
1846
|
+
* `'publish'` otherwise. That keeps the event truthful about which door it
|
|
1847
|
+
* came through instead of hard-coding one.
|
|
1848
|
+
*/
|
|
1849
|
+
private installSaveAdvisoryInterceptor(): void {
|
|
1850
|
+
const meta = this.client.meta;
|
|
1851
|
+
const original = meta.saveItem.bind(meta);
|
|
1852
|
+
meta.saveItem = async (type: string, name: string, item: any) => {
|
|
1853
|
+
const result = await original(type, name, item);
|
|
1854
|
+
// Everything below is best-effort by construction: the row is already
|
|
1855
|
+
// committed server-side, so nothing the advisory channel does may change
|
|
1856
|
+
// what this call returns or whether it throws.
|
|
1857
|
+
try {
|
|
1858
|
+
const advisories = readSaveAdvisories(result);
|
|
1859
|
+
if (advisories.length > 0) {
|
|
1860
|
+
this.emitSaveAdvisory({
|
|
1861
|
+
type,
|
|
1862
|
+
name,
|
|
1863
|
+
mode: (result as { state?: string } | null | undefined)?.state === 'draft' ? 'draft' : 'publish',
|
|
1864
|
+
advisories,
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
} catch (err) {
|
|
1868
|
+
/* an advisory must never turn a committed save into a thrown error */
|
|
1869
|
+
console.warn('ObjectStackAdapter: save-advisory read error', err);
|
|
1870
|
+
}
|
|
1871
|
+
return result;
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1447
1875
|
async create(resource: string, data: Partial<T>): Promise<T> {
|
|
1448
1876
|
await this.connect();
|
|
1449
1877
|
try {
|
|
@@ -1518,10 +1946,21 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1518
1946
|
String(id),
|
|
1519
1947
|
opts?.ifMatch ? { ifMatch: opts.ifMatch } : undefined,
|
|
1520
1948
|
);
|
|
1521
|
-
|
|
1949
|
+
// `success`, not `deleted` (objectstack#5638). `DeleteDataResult.deleted`
|
|
1950
|
+
// was a key no schema ever declared and no server path ever returned on
|
|
1951
|
+
// `DELETE /data/:object/:id` — the client's interface was a wrong CLAIM
|
|
1952
|
+
// about the response body, and `@objectstack/client` 17.0.0-rc.5
|
|
1953
|
+
// corrected it to the schema's `success`.
|
|
1954
|
+
//
|
|
1955
|
+
// This was live here, not cosmetic: `result.deleted` compiled and read
|
|
1956
|
+
// `undefined` at runtime, so the guard below never fired — a successful
|
|
1957
|
+
// delete emitted NO mutation event, leaving every subscriber's cache
|
|
1958
|
+
// stale — and this method, declared `Promise<boolean>`, actually resolved
|
|
1959
|
+
// `undefined`. Following the rename is what restores both.
|
|
1960
|
+
if (result.success) {
|
|
1522
1961
|
this.emitMutation({ type: 'delete', resource, id });
|
|
1523
1962
|
}
|
|
1524
|
-
return result.
|
|
1963
|
+
return result.success;
|
|
1525
1964
|
} catch (err) {
|
|
1526
1965
|
throw normaliseClientError(err);
|
|
1527
1966
|
}
|
|
@@ -2230,6 +2669,16 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2230
2669
|
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
|
|
2231
2670
|
const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText) as any;
|
|
2232
2671
|
err.status = res.status;
|
|
2672
|
+
// Carry the ADR-0112 envelope, not just the status. This branch bypasses
|
|
2673
|
+
// `@objectstack/client` — whose fetch wrapper stamps `code`/`httpStatus`
|
|
2674
|
+
// from the error body — so dropping the code here made THIS path (the one
|
|
2675
|
+
// taken whenever the view expands a lookup or runs a search) the only list
|
|
2676
|
+
// fetch on which a semantic denial arrives indistinguishable from any
|
|
2677
|
+
// other 404/405, leaving the surface nothing to discriminate on
|
|
2678
|
+
// (objectui#4408). Same precedence as the client's wrapper: the top-level
|
|
2679
|
+
// `code` first, then the nested envelope's.
|
|
2680
|
+
err.code = errorBody?.code ?? errorBody?.error?.code;
|
|
2681
|
+
err.httpStatus = res.status;
|
|
2233
2682
|
throw err;
|
|
2234
2683
|
}
|
|
2235
2684
|
|
|
@@ -2603,43 +3052,121 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2603
3052
|
}
|
|
2604
3053
|
}
|
|
2605
3054
|
|
|
3055
|
+
/**
|
|
3056
|
+
* **The one place that knows which cache keys a view-row write invalidates.**
|
|
3057
|
+
*
|
|
3058
|
+
* Two reads are cached under view-shaped keys, and both go stale when any
|
|
3059
|
+
* writer touches a view row for `objectName`:
|
|
3060
|
+
*
|
|
3061
|
+
* | reader | key |
|
|
3062
|
+
* |----------------------------|----------------------------|
|
|
3063
|
+
* | {@link getView} | `view:{object}:{viewName}` |
|
|
3064
|
+
* | {@link listViewOverrides} | `view-overrides:{object}` |
|
|
3065
|
+
*
|
|
3066
|
+
* Every writer routes here rather than restating that pair. The repo has now
|
|
3067
|
+
* paid twice for restatement: objectui#3778 removed five copies of a key
|
|
3068
|
+
* (`views:{object}`) that no reader had ever populated, and objectui#4363
|
|
3069
|
+
* fixed four copies that named only half the live set — and objectui#4373 is
|
|
3070
|
+
* the measured proof that the failure recurs by default, because the console's
|
|
3071
|
+
* real create/publish flow writes these same rows through the ADR-0034
|
|
3072
|
+
* metadata seam and never learned the key list at all. A pin suite can only
|
|
3073
|
+
* guard the writers that exist; a mandatory seam makes the next writer
|
|
3074
|
+
* structurally unable to forget.
|
|
3075
|
+
*
|
|
3076
|
+
* The rule is uniform per WRITE, not per branch: a write to a view row drops
|
|
3077
|
+
* both keys, in that order. Draft-addressed writes over-invalidate on purpose
|
|
3078
|
+
* — both readers enumerate PUBLISHED rows, so a draft write stales neither —
|
|
3079
|
+
* because the costs are not symmetric. An unnecessary invalidation costs one
|
|
3080
|
+
* refetch; a missed one costs up to the cache's 5-minute TTL of stale
|
|
3081
|
+
* overrides, and "which half am I in?" is not a question a future writer
|
|
3082
|
+
* should have to re-answer to stay correct.
|
|
3083
|
+
*
|
|
3084
|
+
* Public because the writers are not all in this class: app-shell's ADR-0034
|
|
3085
|
+
* seam (`runtime-metadata-persistence.ts`) and `MetadataService` write the
|
|
3086
|
+
* same rows through `client.meta` / the `/meta` draft-publish API, and they
|
|
3087
|
+
* reach this adapter as the `dataSource` they were already handed.
|
|
3088
|
+
*
|
|
3089
|
+
* @param objectName - Object the view belongs to (the `{object}` in both keys)
|
|
3090
|
+
* @param viewName - The view's canonical `name` — the identity
|
|
3091
|
+
* {@link getView} reads back by and {@link listViewOverrides} keys its map
|
|
3092
|
+
* with. Callers that hold a qualified `<object>.<key>` name pass it as-is:
|
|
3093
|
+
* it is the row key, not a path to be split.
|
|
3094
|
+
*/
|
|
3095
|
+
invalidateViewKeys(objectName: string, viewName: string): void {
|
|
3096
|
+
// Ordered per-view key first, then the object's map — the order the pin
|
|
3097
|
+
// suite records, so a reordering is a visible decision rather than drift.
|
|
3098
|
+
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
|
|
3099
|
+
this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
|
|
3100
|
+
}
|
|
3101
|
+
|
|
2606
3102
|
/**
|
|
2607
3103
|
* Batch-fetch all persisted view overrides for an object.
|
|
2608
3104
|
*
|
|
2609
|
-
* Per-view runtime overrides (density, column widths, sort,
|
|
2610
|
-
*
|
|
2611
|
-
*
|
|
2612
|
-
*
|
|
2613
|
-
*
|
|
2614
|
-
*
|
|
2615
|
-
* returns
|
|
2616
|
-
*
|
|
3105
|
+
* Per-view runtime overrides (density, column widths, sort, hidden
|
|
3106
|
+
* columns, inlineEdit …) live in the SAME metadata namespace the
|
|
3107
|
+
* write path uses: `type='view'`, `name=<viewId>` (see
|
|
3108
|
+
* {@link updateViewConfig}). Loading them per-view fires N HTTP GETs
|
|
3109
|
+
* that 404 for every view the user never customized — console noise on
|
|
3110
|
+
* every page load. This batch method performs a single
|
|
3111
|
+
* `GET /api/v1/meta/view` (returns `{type, items}`) and narrows the
|
|
3112
|
+
* result to `objectName` client-side, exactly as {@link listViews}
|
|
3113
|
+
* does over the same rows (shared accessor: {@link viewItemObjectName}).
|
|
3114
|
+
*
|
|
3115
|
+
* objectui#3774 — this used to enumerate `GET /api/v1/meta/<objectName>`,
|
|
3116
|
+
* putting the OBJECT name in the metadata TYPE slot. That key space is
|
|
3117
|
+
* disjoint from the one the write path lands in, so the batch map came
|
|
3118
|
+
* back empty for every object, forever, and every saved personalization
|
|
3119
|
+
* read back as "setting didn't save".
|
|
3120
|
+
*
|
|
3121
|
+
* FAILURES REJECT — they are not answered as `{}`. An empty map is an
|
|
3122
|
+
* authoritative "this object has no overrides" (callers may trust it and
|
|
3123
|
+
* skip the per-view reads); a transport/permission failure is "I could
|
|
3124
|
+
* not tell", and reporting the two as the same value is what made
|
|
3125
|
+
* ObjectView's per-view {@link getView} fallback unreachable code. When
|
|
3126
|
+
* we cannot tell, we do not pretend we can. Rejections are not cached
|
|
3127
|
+
* (the cache stores on success only), so a transient failure does not
|
|
3128
|
+
* pin an empty answer for the TTL.
|
|
2617
3129
|
*
|
|
2618
|
-
* Result is cached identically to {@link getView}
|
|
2619
|
-
*
|
|
3130
|
+
* Result is cached identically to {@link getView}, and EVERY view write —
|
|
3131
|
+
* on this adapter and above it — invalidates it through the one seam,
|
|
3132
|
+
* {@link invalidateViewKeys}. For a long time only {@link updateViewConfig}
|
|
3133
|
+
* did (objectui#4363), which left the other three adapter paths stale
|
|
3134
|
+
* for the cache's 5-minute TTL. That gap does not self-heal: the consumer
|
|
3135
|
+
* (`loadViewOverrides`, app-shell `ObjectView`) treats a RESOLVED map as
|
|
3136
|
+
* authoritative and deliberately does not re-probe per view — objectui#3774,
|
|
3137
|
+
* and correct, since re-probing reinstates the 404 flurry the batch read
|
|
3138
|
+
* exists to remove. So a stale map here is served in full, and the per-view
|
|
3139
|
+
* {@link getView} fallback that would have masked it never runs.
|
|
2620
3140
|
*
|
|
2621
3141
|
* @param objectName - Object name (e.g. 'lead')
|
|
2622
3142
|
* @returns Map keyed by view name with the persisted override config
|
|
3143
|
+
* @throws whatever the metadata transport throws — callers that have a
|
|
3144
|
+
* per-view fallback should catch and use it.
|
|
2623
3145
|
*/
|
|
2624
3146
|
async listViewOverrides(objectName: string): Promise<Record<string, any>> {
|
|
2625
3147
|
await this.connect();
|
|
2626
3148
|
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
3149
|
+
const cacheKey = `view-overrides:${objectName}`;
|
|
3150
|
+
return await this.metadataCache.get(cacheKey, async () => {
|
|
3151
|
+
const result: any = await this.client.meta.getItems('view');
|
|
3152
|
+
const items: any[] = Array.isArray(result?.items)
|
|
3153
|
+
? result.items
|
|
3154
|
+
: Array.isArray(result) ? result : [];
|
|
3155
|
+
const out: Record<string, any> = {};
|
|
3156
|
+
for (const it of items) {
|
|
3157
|
+
if (!it || typeof it !== 'object') continue;
|
|
3158
|
+
if (viewItemObjectName(it) !== objectName) continue;
|
|
3159
|
+
// Keyed by the item's canonical `name` — the SAME identity
|
|
3160
|
+
// `updateViewConfig` writes under and `getView` reads back by, which
|
|
3161
|
+
// is what makes this a drop-in substitute for the per-view fetch.
|
|
3162
|
+
// No `?? id ?? _name` alias chain: those are not view identities on
|
|
3163
|
+
// any route (`/meta/view/:name` is name-addressed), and a batch map
|
|
3164
|
+
// keyed by something no caller can ask for is dead weight.
|
|
3165
|
+
const key = it.name;
|
|
3166
|
+
if (typeof key === 'string' && key) out[key] = it;
|
|
3167
|
+
}
|
|
3168
|
+
return out;
|
|
3169
|
+
});
|
|
2643
3170
|
}
|
|
2644
3171
|
|
|
2645
3172
|
/**
|
|
@@ -2705,12 +3232,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2705
3232
|
viewId,
|
|
2706
3233
|
merged
|
|
2707
3234
|
);
|
|
2708
|
-
//
|
|
2709
|
-
|
|
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}`);
|
|
3235
|
+
// One seam names the key set for every writer (objectui#4373).
|
|
3236
|
+
this.invalidateViewKeys(objectName, viewId);
|
|
2714
3237
|
if (result && result.item) return result.item;
|
|
2715
3238
|
return result ?? undefined;
|
|
2716
3239
|
}
|
|
@@ -2722,8 +3245,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2722
3245
|
* match the view spec shape.
|
|
2723
3246
|
*
|
|
2724
3247
|
* Returns view spec objects with their canonical `name` as identifier.
|
|
2725
|
-
*
|
|
2726
|
-
*
|
|
3248
|
+
* Narrows to one object client-side via {@link viewItemObjectName} —
|
|
3249
|
+
* the metadata index is name-only, not field-typed, so the route has no
|
|
3250
|
+
* `?object=` to push the filter down into. {@link listViewOverrides}
|
|
3251
|
+
* reads the same rows through the same accessor.
|
|
2727
3252
|
*/
|
|
2728
3253
|
async listViews(
|
|
2729
3254
|
objectName: string,
|
|
@@ -2762,8 +3287,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2762
3287
|
if (!v) return false;
|
|
2763
3288
|
// Handle both bare view spec and `{list: {...}}` artifact wrapper
|
|
2764
3289
|
const spec = v.list ?? v;
|
|
2765
|
-
|
|
2766
|
-
if (obj !== objectName) return false;
|
|
3290
|
+
if (viewItemObjectName(v) !== objectName) return false;
|
|
2767
3291
|
const viewKind = v.viewKind ?? spec?.viewKind;
|
|
2768
3292
|
return !(viewKind && FORM_FAMILY.has(viewKind));
|
|
2769
3293
|
}).map((v: any) => {
|
|
@@ -2841,6 +3365,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2841
3365
|
*
|
|
2842
3366
|
* Generates a snake_case name if `spec.name` is not provided by appending
|
|
2843
3367
|
* a short timestamp suffix to the source-name hint.
|
|
3368
|
+
*
|
|
3369
|
+
* Invalidates through {@link invalidateViewKeys}, like every other writer —
|
|
3370
|
+
* see {@link listViewOverrides} for why the batch map is the one that cannot
|
|
3371
|
+
* heal itself (objectui#4363).
|
|
2844
3372
|
*/
|
|
2845
3373
|
async createView(
|
|
2846
3374
|
objectName: string,
|
|
@@ -2870,16 +3398,57 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2870
3398
|
data: spec?.data || { provider: 'object', object: objectName },
|
|
2871
3399
|
};
|
|
2872
3400
|
const result: any = await this.client.meta.saveItem('view', name, fullSpec);
|
|
2873
|
-
|
|
3401
|
+
// `saveItem` is an UPSERT: an explicit `spec.name` that already exists
|
|
3402
|
+
// overwrites the published row, which a prior `getView` may hold. (A
|
|
3403
|
+
// generated name cannot collide, and a miss is a `Map.delete` on an absent
|
|
3404
|
+
// key, so the uniform rule costs nothing on the create-a-new-row path.)
|
|
3405
|
+
this.invalidateViewKeys(objectName, name);
|
|
2874
3406
|
if (result && result.item) return result.item;
|
|
2875
3407
|
return fullSpec;
|
|
2876
3408
|
}
|
|
2877
3409
|
|
|
2878
3410
|
/**
|
|
2879
3411
|
* Apply a partial update to an existing overlay view. Reads the current
|
|
2880
|
-
*
|
|
2881
|
-
*
|
|
2882
|
-
*
|
|
3412
|
+
* document, merges, and writes it back. ADR-0005 overlay rows store the
|
|
3413
|
+
* *full* view document, so partial updates require a read-merge-write cycle.
|
|
3414
|
+
*
|
|
3415
|
+
* **Both halves address the same row (#4139).** A view has two possible
|
|
3416
|
+
* homes and the read must resolve the one the write will target:
|
|
3417
|
+
*
|
|
3418
|
+
* - a pending per-item **draft** (`?state=draft` / `?mode=draft`) — where
|
|
3419
|
+
* ADR-0034 stages every runtime-created view, so a view made from the `+`
|
|
3420
|
+
* tab lives ONLY here until an explicit Publish;
|
|
3421
|
+
* - the **published** overlay (`client.meta.getItem` / `saveItem`).
|
|
3422
|
+
*
|
|
3423
|
+
* The draft is probed FIRST, and a hit is merged and written straight back
|
|
3424
|
+
* as a draft. Two things that ordering buys, both load-bearing:
|
|
3425
|
+
*
|
|
3426
|
+
* 1. A draft-only view is no longer invisible to the read. It used to 404,
|
|
3427
|
+
* and a `catch {}` labelled "treat missing as create-equivalent"
|
|
3428
|
+
* substituted `current = {}` — so a rename merged onto nothing and went
|
|
3429
|
+
* out as a `{label, name, object}` partial the server rejects (422),
|
|
3430
|
+
* while the draft row the UI reads back through `?preview=draft` kept the
|
|
3431
|
+
* old label. The edit was lost with no error surfaced to the user.
|
|
3432
|
+
* 2. A draft is never bypassed. Writing the published row while a draft is
|
|
3433
|
+
* pending would put the edit somewhere the draft shadows — and Publish
|
|
3434
|
+
* would then overwrite it with the pre-edit body, losing the change a
|
|
3435
|
+
* second time, later, where nothing connects it to this call.
|
|
3436
|
+
*
|
|
3437
|
+
* A draft edit stays a draft: `mode: 'draft'` keeps ADR-0037's guarantee
|
|
3438
|
+
* that nothing the preview shows goes live until Publish. Renaming a
|
|
3439
|
+
* *published* view (no draft pending) is unchanged — it writes the
|
|
3440
|
+
* published overlay, as before.
|
|
3441
|
+
*
|
|
3442
|
+
* **Both halves invalidate through {@link invalidateViewKeys}** (objectui#4363,
|
|
3443
|
+
* #4373) — same call, same key set, so the draft half's deliberate
|
|
3444
|
+
* over-invalidation is the seam's uniform rule rather than a per-branch
|
|
3445
|
+
* decision this method has to keep re-making.
|
|
3446
|
+
*
|
|
3447
|
+
* @throws when the view resolves in neither home, or when either read fails
|
|
3448
|
+
* for any other reason (network, permission). Both used to be swallowed
|
|
3449
|
+
* and converted into the bad partial write above; a caller that wants a
|
|
3450
|
+
* view created should call {@link createView}, which is the operation that
|
|
3451
|
+
* actually means "create".
|
|
2883
3452
|
*/
|
|
2884
3453
|
async updateView(
|
|
2885
3454
|
objectName: string,
|
|
@@ -2887,42 +3456,159 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2887
3456
|
partial: Record<string, any>,
|
|
2888
3457
|
): Promise<Record<string, any> | void> {
|
|
2889
3458
|
await this.connect();
|
|
2890
|
-
|
|
3459
|
+
|
|
3460
|
+
// ── Draft-addressed path ────────────────────────────────────────────
|
|
3461
|
+
// `MetadataClient.get` answers `null` on 404 (no draft pending) and throws
|
|
3462
|
+
// on anything else, so a transport failure here is NOT read as "published".
|
|
3463
|
+
const metaClient = this.metadataClient();
|
|
3464
|
+
const draft = unwrapViewDraft(
|
|
3465
|
+
await metaClient.get('view', viewName, { state: 'draft' }),
|
|
3466
|
+
);
|
|
3467
|
+
if (draft) {
|
|
3468
|
+
const mergedDraft = mergeViewPatch(draft, partial, viewName, objectName);
|
|
3469
|
+
await metaClient.save('view', viewName, mergedDraft, { mode: 'draft' });
|
|
3470
|
+
this.invalidateViewKeys(objectName, viewName);
|
|
3471
|
+
return mergedDraft;
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
// ── Published-overlay path (unchanged addressing) ────────────────────
|
|
3475
|
+
let current: any;
|
|
2891
3476
|
try {
|
|
2892
3477
|
const r: any = await this.client.meta.getItem('view', viewName);
|
|
2893
3478
|
current = (r && (r.item || r)) || {};
|
|
2894
3479
|
// Some endpoints return the bare item; others wrap as {type,name,item}
|
|
2895
3480
|
if (current?.list) current = current.list;
|
|
2896
|
-
} catch {
|
|
2897
|
-
|
|
3481
|
+
} catch (err) {
|
|
3482
|
+
if (is404Error(err)) {
|
|
3483
|
+
// Not a draft, not published, not an artifact — there is nothing to
|
|
3484
|
+
// merge onto. Fail loudly instead of emitting the partial write.
|
|
3485
|
+
throw Object.assign(
|
|
3486
|
+
new Error(
|
|
3487
|
+
`updateView: view "${viewName}" not found on object "${objectName}"` +
|
|
3488
|
+
' — no pending draft and no published overlay. Use createView() to create one.',
|
|
3489
|
+
),
|
|
3490
|
+
{ cause: err },
|
|
3491
|
+
);
|
|
3492
|
+
}
|
|
3493
|
+
// Network / permission / server fault: surface it. Degrading to a
|
|
3494
|
+
// create-equivalent write here is what corrupted the row before.
|
|
3495
|
+
throw err;
|
|
2898
3496
|
}
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
...partial,
|
|
2902
|
-
name: viewName,
|
|
2903
|
-
object: current?.object || (current as any)?.data?.object || objectName,
|
|
2904
|
-
};
|
|
3497
|
+
|
|
3498
|
+
const merged = mergeViewPatch(current, partial, viewName, objectName);
|
|
2905
3499
|
const result: any = await this.client.meta.saveItem('view', viewName, merged);
|
|
2906
|
-
this.
|
|
2907
|
-
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
|
|
3500
|
+
this.invalidateViewKeys(objectName, viewName);
|
|
2908
3501
|
if (result && result.item) return result.item;
|
|
2909
3502
|
return merged;
|
|
2910
3503
|
}
|
|
2911
3504
|
|
|
2912
3505
|
/**
|
|
2913
|
-
* Delete an overlay view
|
|
2914
|
-
*
|
|
2915
|
-
*
|
|
3506
|
+
* Delete an overlay view — from **every home it has**.
|
|
3507
|
+
*
|
|
3508
|
+
* A view has two possible homes, the same two {@link updateView} addresses:
|
|
3509
|
+
* the pending per-item **draft** (`DELETE /meta/view/:name?state=draft`) and
|
|
3510
|
+
* the **published** overlay (`DELETE /meta/view/:name`). This method used to
|
|
3511
|
+
* issue only the second, and the three cases came out like this (#4479):
|
|
3512
|
+
*
|
|
3513
|
+
* | case | before |
|
|
3514
|
+
* |--------------------|-----------------------------------------------------|
|
|
3515
|
+
* | draft-only view | BUG — the published delete answered `reset:false` / |
|
|
3516
|
+
* | | "nothing to delete", the draft survived, and the tab |
|
|
3517
|
+
* | | was still there after reload |
|
|
3518
|
+
* | published-only | correct — `reset:true`, tab gone |
|
|
3519
|
+
* | published + draft | ACCIDENTALLY correct — the published row went, so |
|
|
3520
|
+
* | | the tab went; the orphan draft stayed behind |
|
|
3521
|
+
*
|
|
3522
|
+
* **Why this is not the mechanical mirror of #4139.** `updateView` probes
|
|
3523
|
+
* the draft first and writes back to whichever home the read resolved, and
|
|
3524
|
+
* that is right for an update in all three cases. Copying it here would be
|
|
3525
|
+
* wrong in the third: `persistRuntimeMetadata` (app-shell) stages EVERY
|
|
3526
|
+
* runtime edit as a draft, so "publish a view, then edit it" routinely
|
|
3527
|
+
* produces a pair — and a draft-first-only delete on a pair discards the
|
|
3528
|
+
* draft and leaves the published row serving the view. That is not Delete
|
|
3529
|
+
* view, it is **Discard draft**, a deliberately different operation that
|
|
3530
|
+
* already exists (`discardRuntimeDraft`, documented as "the published
|
|
3531
|
+
* overlay is untouched"). The clean statement of the asymmetry: for an
|
|
3532
|
+
* update, one home is the right home; for a delete, "remove this view" is
|
|
3533
|
+
* satisfied only when NO home is left serving it.
|
|
3534
|
+
*
|
|
3535
|
+
* So both homes are deleted, **draft first**. The order is load-bearing on
|
|
3536
|
+
* the failure path: a fault between the two calls leaves the PUBLISHED
|
|
3537
|
+
* overlay intact, so the view is still served and the operation is cleanly
|
|
3538
|
+
* retryable. The reverse order would strand a draft-only view — which is
|
|
3539
|
+
* precisely the bug shape above.
|
|
3540
|
+
*
|
|
3541
|
+
* **Two blind calls, no probe.** The framework's `deleteMetaItem` answers a
|
|
3542
|
+
* missing home with a **200** carrying `reset:false` (`"No pending draft
|
|
3543
|
+
* for view/x."` / `"No view 'x' found — nothing to delete."`), never a 404,
|
|
3544
|
+
* so there is nothing for a probe to protect against. `updateView` needs its
|
|
3545
|
+
* probe for a different reason — its read must resolve the row the merge
|
|
3546
|
+
* writes back to — and that reason has no counterpart for a delete.
|
|
3547
|
+
*
|
|
3548
|
+
* **One transport, one error contract.** Both halves go through
|
|
3549
|
+
* {@link MetadataClient} (`reset`), the transport that can express the
|
|
3550
|
+
* `?state=` qualifier and the one `updateView`'s draft half already uses.
|
|
3551
|
+
* The published half used to go through `client.meta.deleteItem`; measured,
|
|
3552
|
+
* that issues the byte-identical request (`DELETE
|
|
3553
|
+
* {baseUrl}/api/v1/meta/view/:name`, no environment scoping is configured on
|
|
3554
|
+
* this adapter), so routing it here costs no addressing change and buys a
|
|
3555
|
+
* single `MetadataError` shape across both calls instead of two.
|
|
3556
|
+
*
|
|
3557
|
+
* @returns `deleted` is true only when no home is left serving the view AND
|
|
3558
|
+
* at least one actually held a row. A view that existed in neither home
|
|
3559
|
+
* still answers `false`, unchanged. The per-home outcomes are additive:
|
|
3560
|
+
* a partial result is observable rather than rounded up to `true`.
|
|
3561
|
+
* @throws when either delete fails, matching {@link updateView}'s
|
|
3562
|
+
* convention of surfacing the fault rather than degrading. A failure of
|
|
3563
|
+
* the PUBLISHED half after the draft was discarded carries the partial
|
|
3564
|
+
* state on the error's `outcome` — "draft gone, overlay left" is exactly
|
|
3565
|
+
* what the old `{ deleted: boolean }` could not express.
|
|
3566
|
+
*
|
|
3567
|
+
* Invalidates through {@link invalidateViewKeys}: the deleted row leaves the
|
|
3568
|
+
* batch override map too, and a ghost entry there is what the object page
|
|
3569
|
+
* would keep applying (objectui#4363). Fired in a `finally`, so it happens
|
|
3570
|
+
* once per call on EVERY outcome including the throw — after a half-failure
|
|
3571
|
+
* the draft row really is gone, and #4363's asymmetry decides it: an
|
|
3572
|
+
* unnecessary invalidation costs one refetch, a missed one costs the cache's
|
|
3573
|
+
* full 5-minute TTL of stale overrides.
|
|
2916
3574
|
*/
|
|
2917
3575
|
async deleteView(
|
|
2918
3576
|
objectName: string,
|
|
2919
3577
|
viewName: string,
|
|
2920
|
-
): Promise<
|
|
3578
|
+
): Promise<DeleteViewResult> {
|
|
2921
3579
|
await this.connect();
|
|
2922
|
-
const
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
3580
|
+
const metaClient = this.metadataClient();
|
|
3581
|
+
try {
|
|
3582
|
+
// ── Draft home, first ────────────────────────────────────────────────
|
|
3583
|
+
const draft = readViewDeleteReceipt(
|
|
3584
|
+
await metaClient.reset('view', viewName, { state: 'draft' }),
|
|
3585
|
+
);
|
|
3586
|
+
|
|
3587
|
+
// ── Published home ───────────────────────────────────────────────────
|
|
3588
|
+
let published: ViewHomeDeleteOutcome;
|
|
3589
|
+
try {
|
|
3590
|
+
published = readViewDeleteReceipt(await metaClient.reset('view', viewName));
|
|
3591
|
+
} catch (err) {
|
|
3592
|
+
const outcome: DeleteViewResult = {
|
|
3593
|
+
deleted: false,
|
|
3594
|
+
draft,
|
|
3595
|
+
published: { removed: false },
|
|
3596
|
+
};
|
|
3597
|
+
throw Object.assign(
|
|
3598
|
+
new Error(
|
|
3599
|
+
`deleteView: view "${viewName}" on object "${objectName}" is NOT fully removed` +
|
|
3600
|
+
` — the draft home was ${draft.removed ? 'discarded' : 'already absent'},` +
|
|
3601
|
+
' but deleting the published overlay failed. The published row is still' +
|
|
3602
|
+
' serving the view; retry the delete.',
|
|
3603
|
+
),
|
|
3604
|
+
{ cause: err, outcome },
|
|
3605
|
+
);
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3608
|
+
return { deleted: draft.removed || published.removed, draft, published };
|
|
3609
|
+
} finally {
|
|
3610
|
+
this.invalidateViewKeys(objectName, viewName);
|
|
3611
|
+
}
|
|
2926
3612
|
}
|
|
2927
3613
|
|
|
2928
3614
|
|
|
@@ -2951,6 +3637,58 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2951
3637
|
}
|
|
2952
3638
|
}
|
|
2953
3639
|
|
|
3640
|
+
/**
|
|
3641
|
+
* Ask the by-name meta app route WHY an app is not in this session's app list
|
|
3642
|
+
* (objectui#4252).
|
|
3643
|
+
*
|
|
3644
|
+
* `GET /api/v1/meta/apps` is filtered per session server-side
|
|
3645
|
+
* (`filterAppForUser`), so an app withheld by `requiredPermissions` and an app
|
|
3646
|
+
* that does not exist are byte-identical there: both are simply absent. A
|
|
3647
|
+
* console reading only that list has one fact and two conditions, and it
|
|
3648
|
+
* renders its copy for the wrong one — "it may still be publishing" over a
|
|
3649
|
+
* permanent authorization decision, which cost a downstream acceptance round
|
|
3650
|
+
* two test batches spent chasing a platform defect that was a missing
|
|
3651
|
+
* permission-set binding.
|
|
3652
|
+
*
|
|
3653
|
+
* The maintainer ruling (2026-08-12) put the answer on the BY-NAME route
|
|
3654
|
+
* rather than in the list, so the enumeration surface is not widened past what
|
|
3655
|
+
* a by-name probe already implies (objectstack#8013 / PR #8135): an app that
|
|
3656
|
+
* exists and whose `requiredPermissions` the session lacks answers `403` with
|
|
3657
|
+
* `PERMISSION_DENIED` in the declared envelope, and absence — a nonexistent
|
|
3658
|
+
* name, an unpublished app, an app gated by an absent optional service —
|
|
3659
|
+
* keeps answering `404 RESOURCE_NOT_FOUND`.
|
|
3660
|
+
*
|
|
3661
|
+
* ## Why this is a separate method and not a flavour of {@link getApp}
|
|
3662
|
+
*
|
|
3663
|
+
* - `getApp` degrades EVERY failure to `null`, which is exactly the
|
|
3664
|
+
* conflation this exists to undo; changing it would silently re-point its
|
|
3665
|
+
* own callers' fallback-to-static-config path.
|
|
3666
|
+
* - `getApp` memoises in `metadataCache`. A verdict about the CALLER must not
|
|
3667
|
+
* be cached beside a document about the APP — one grant, and a cached
|
|
3668
|
+
* denial outlives the session it described.
|
|
3669
|
+
*
|
|
3670
|
+
* Nothing here throws: a probe that cannot reach an answer returns `unknown`
|
|
3671
|
+
* and the caller keeps whatever it was already showing. Only the measured
|
|
3672
|
+
* `code` produces `denied` — never a status, never a message (objectui#4408).
|
|
3673
|
+
*
|
|
3674
|
+
* @param appName - the app name as it appears in the URL segment
|
|
3675
|
+
*/
|
|
3676
|
+
async probeAppAccess(appName: string): Promise<AppAccessVerdict> {
|
|
3677
|
+
if (!appName) return 'unknown';
|
|
3678
|
+
try {
|
|
3679
|
+
// Singular `app`, the address objectstack#8013 pinned its cases against,
|
|
3680
|
+
// and the same one `MetadataProvider` reads items by. No `connect()`
|
|
3681
|
+
// first: this route is only ever asked after the app LIST has already
|
|
3682
|
+
// loaded through this same client, and the client's route resolution
|
|
3683
|
+
// falls back to the conventional `/api/v1/meta` regardless — so a
|
|
3684
|
+
// discovery round trip here could only add a failure mode.
|
|
3685
|
+
await this.client.meta.getItem('app', appName);
|
|
3686
|
+
return 'granted';
|
|
3687
|
+
} catch (err) {
|
|
3688
|
+
return isAppPermissionDeniedError(err) ? 'denied' : 'unknown';
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
|
|
2954
3692
|
/**
|
|
2955
3693
|
* Get a page definition from ObjectStack.
|
|
2956
3694
|
* Uses the metadata API to fetch page layouts.
|
|
@@ -3179,36 +3917,70 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
3179
3917
|
* silently returning wrong numbers.
|
|
3180
3918
|
*
|
|
3181
3919
|
* @param dataset - An inline dataset definition (draft) OR a saved dataset name.
|
|
3182
|
-
* @param selection -
|
|
3920
|
+
* @param selection - The spec's {@link DatasetSelection} — dimension/measure
|
|
3921
|
+
* names to project plus runtime directives. This parameter IS the spec type
|
|
3922
|
+
* by reference, never a local restatement of it (objectui#3613): a hand
|
|
3923
|
+
* copy of a contract is a second dialect of it, and the copy this replaced
|
|
3924
|
+
* had already drifted three ways from `@objectstack/spec` — it required
|
|
3925
|
+
* `compareTo.dimension` (optional since objectstack#5011, and resolved by
|
|
3926
|
+
* the EXECUTOR, so requiring it pushed callers into exactly the
|
|
3927
|
+
* consumer-side dimension guess AGENTS.md #0.1 forbids), it widened
|
|
3928
|
+
* `timeDimensions` to `unknown[]` and `runtimeFilter` to
|
|
3929
|
+
* `Record<string, unknown>`, and it had never grown `dateGranularity` at
|
|
3930
|
+
* all. Pinned in `queryDataset.test.ts`.
|
|
3183
3931
|
*/
|
|
3184
3932
|
async queryDataset(
|
|
3185
3933
|
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
|
-
},
|
|
3934
|
+
selection: DatasetSelection,
|
|
3201
3935
|
): Promise<{
|
|
3202
3936
|
rows: Array<Record<string, unknown>>;
|
|
3203
|
-
/**
|
|
3204
|
-
*
|
|
3205
|
-
|
|
3937
|
+
/**
|
|
3938
|
+
* Column metadata — the spec's `AnalyticsResult.fields[]` element BY
|
|
3939
|
+
* REFERENCE, never a local restatement of it (objectui#3752). Read
|
|
3940
|
+
* `@objectstack/spec` for what a column carries; this comment deliberately
|
|
3941
|
+
* does not re-list the keys, because the enumeration it replaced was the
|
|
3942
|
+
* bug: it named five (`name`/`type`/`label`/`format`/`currency`) and stopped
|
|
3943
|
+
* at the contract of the day it was written, so it never grew
|
|
3944
|
+
* `percentScale` — the server's answer to whether a percentage column is a
|
|
3945
|
+
* 0–1 fraction or already percentage points. The spec says a renderer that
|
|
3946
|
+
* receives it "must scale by it instead of guessing from the value"
|
|
3947
|
+
* (objectui#3136), so a declaration that hides the key steers a typed
|
|
3948
|
+
* consumer into exactly the guess-by-magnitude the issue banned. Pinned in
|
|
3949
|
+
* `queryDataset.test.ts`.
|
|
3950
|
+
*
|
|
3951
|
+
* Only this element is spec-owned: the envelope around it (`object` /
|
|
3952
|
+
* `dimensionFields` / `drillRawRows`) is ADR-0021 D2 drill metadata the REST
|
|
3953
|
+
* route adds on top of `AnalyticsResult`, and this method never returns the
|
|
3954
|
+
* result's `sql`, so the whole envelope is NOT an `AnalyticsResult`.
|
|
3955
|
+
*/
|
|
3956
|
+
fields: Array<AnalyticsResult['fields'][number]>;
|
|
3206
3957
|
/** ADR-0021 D2 drill-through: the dataset's base object (records to drill into). */
|
|
3207
3958
|
object?: string;
|
|
3208
3959
|
/** Drillable dimension NAME → underlying object FIELD name. */
|
|
3209
3960
|
dimensionFields?: Record<string, string>;
|
|
3210
3961
|
/** Raw grouped values per row (aligned to `rows` by index) for drill filters. */
|
|
3211
3962
|
drillRawRows?: Array<Record<string, unknown>>;
|
|
3963
|
+
/**
|
|
3964
|
+
* Half-open date-range drill scope per row (framework#1752), aligned to
|
|
3965
|
+
* `rows` by index: dimension NAME → the field and `[gte, lt)` bounds of that
|
|
3966
|
+
* row's time bucket. The RANGE companion to `drillRawRows`, which handles
|
|
3967
|
+
* equality dims only — a `dateGranularity` dimension groups a SPAN of
|
|
3968
|
+
* records into one bucket, so the server excludes date dims from
|
|
3969
|
+
* `dimensionFields`/`drillRawRows` and sends this sidecar instead.
|
|
3970
|
+
*
|
|
3971
|
+
* The entry type is `@object-ui/core`'s `DatasetDrillRange` BY REFERENCE,
|
|
3972
|
+
* not a local restatement of it (objectui#3613/#3752 discipline): the same
|
|
3973
|
+
* declaration is what `buildDatasetDrillFilter` — the single consumer that
|
|
3974
|
+
* turns these bounds into an ObjectQL `{ $gte, $lt }` — accepts, and what
|
|
3975
|
+
* `DatasetWidget` / `DatasetReportRenderer` type their state with. Nothing
|
|
3976
|
+
* in `@objectstack/spec` owns this shape yet (the server's own
|
|
3977
|
+
* `AnalyticsResultWithDrill` is local to `service-analytics`), so the shared
|
|
3978
|
+
* in-repo interface is the one contract available; restating it here would
|
|
3979
|
+
* make a third dialect of it. Like `drillRawRows`, only the ARRAY is
|
|
3980
|
+
* validated below — the bounds are unvalidated payload, which is exactly why
|
|
3981
|
+
* `DatasetDrillRange` declares them `unknown`.
|
|
3982
|
+
*/
|
|
3983
|
+
drillRanges?: Array<Record<string, DatasetDrillRange>>;
|
|
3212
3984
|
/** Server-computed marginal aggregates, one entry per requested grouping. */
|
|
3213
3985
|
totals?: Array<{ dimensions: string[]; rows: Array<Record<string, unknown>> }>;
|
|
3214
3986
|
}> {
|
|
@@ -3275,8 +4047,16 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
3275
4047
|
? ((data as any).dimensionFields as Record<string, string>)
|
|
3276
4048
|
: undefined;
|
|
3277
4049
|
const drillRawRows = Array.isArray((data as any)?.drillRawRows) ? (data as any).drillRawRows : undefined;
|
|
4050
|
+
// framework#1752 — the date-range sidecar. Dropping it here (objectui#3813)
|
|
4051
|
+
// made date-bucket drill-through impossible through the only real adapter:
|
|
4052
|
+
// a widget grouped ONLY by a date dimension gets no `dimensionFields` (the
|
|
4053
|
+
// server excludes date dims from the equality drill), so `drillRanges` is
|
|
4054
|
+
// the ONLY thing that can make `canDrill` true — with the key hand-picked
|
|
4055
|
+
// away, the whole drill entry point disappeared, and a mixed grouping
|
|
4056
|
+
// drilled to a superset (every bucket, not the clicked one).
|
|
4057
|
+
const drillRanges = Array.isArray((data as any)?.drillRanges) ? (data as any).drillRanges : undefined;
|
|
3278
4058
|
const totals = Array.isArray((data as any)?.totals) ? (data as any).totals : undefined;
|
|
3279
|
-
return { rows, fields, object, dimensionFields, drillRawRows, totals };
|
|
4059
|
+
return { rows, fields, object, dimensionFields, drillRawRows, drillRanges, totals };
|
|
3280
4060
|
}
|
|
3281
4061
|
|
|
3282
4062
|
/** Client-side aggregation fallback */
|
|
@@ -3610,9 +4390,10 @@ export {
|
|
|
3610
4390
|
export type { MetadataCacheStats } from './cache/MetadataCache';
|
|
3611
4391
|
|
|
3612
4392
|
// v3.0.0 Deep Integration modules
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
4393
|
+
// (The cloud deployment/hosting/marketplace surface that used to be re-exported
|
|
4394
|
+
// here was retired by objectui#4152 — it called into a client namespace that
|
|
4395
|
+
// does not exist and fabricated success. `cloud-surface-retired-4152.pin.test.ts`
|
|
4396
|
+
// fails if it returns; that file names the retired symbols, this one must not.)
|
|
3616
4397
|
export { validatePluginContract, generateContractManifest } from './contracts';
|
|
3617
4398
|
|
|
3618
4399
|
// User-scoped persistence adapter (favorites / recent items / …)
|
|
@@ -3630,8 +4411,11 @@ export type { IntegrationConfig, IntegrationTrigger, IntegrationProvider, SlackI
|
|
|
3630
4411
|
// Used by plugin-designer to back the Setup-app Object Manager and Field
|
|
3631
4412
|
// Designer surfaces; kept separate from ObjectStackAdapter so callers
|
|
3632
4413
|
// can use it without the full data-source surface.
|
|
3633
|
-
export { MetadataClient } from './metadata-client';
|
|
4414
|
+
export { MetadataClient, readSaveAdvisories } from './metadata-client';
|
|
3634
4415
|
export type {
|
|
4416
|
+
RuntimeAuthoringIssue,
|
|
4417
|
+
MetadataSaveAdvisoryEvent,
|
|
4418
|
+
MetadataSaveAdvisoryListener,
|
|
3635
4419
|
MetadataClientConfig,
|
|
3636
4420
|
MetadataListOptions,
|
|
3637
4421
|
MetadataDraftHeader,
|