@object-ui/data-objectstack 17.2.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 +772 -0
- package/README.md +3 -11
- package/dist/index.cjs +1232 -699
- package/dist/index.d.cts +1367 -906
- package/dist/index.d.ts +1367 -906
- package/dist/index.js +1222 -696
- 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 +967 -100
- 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/onWriteWarning.test.ts +101 -0
- package/src/queryDataset.test.ts +411 -0
- package/src/spec-symbol-batch6.test.ts +25 -2
- 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,
|
|
@@ -771,9 +940,194 @@ function stableStringify(value: unknown): string {
|
|
|
771
940
|
return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`;
|
|
772
941
|
}
|
|
773
942
|
|
|
943
|
+
/**
|
|
944
|
+
* Whether two values are the SAME as far as the wire is concerned — used to
|
|
945
|
+
* tell a write-strip that lost something from one that lost nothing
|
|
946
|
+
* (objectui#3484).
|
|
947
|
+
*
|
|
948
|
+
* Deliberately strict: `1` and `'1'` are NOT the same here. A false "these are
|
|
949
|
+
* equal" silently swallows a warning about a value the user really did lose,
|
|
950
|
+
* which is the worse of the two errors; a false "these differ" only warns
|
|
951
|
+
* about a no-op. `null` and `undefined` both mean "empty" and do match.
|
|
952
|
+
*/
|
|
953
|
+
function sameWireValue(a: unknown, b: unknown): boolean {
|
|
954
|
+
if (a === b) return true;
|
|
955
|
+
const aEmpty = a === null || a === undefined;
|
|
956
|
+
const bEmpty = b === null || b === undefined;
|
|
957
|
+
if (aEmpty || bEmpty) return aEmpty && bEmpty;
|
|
958
|
+
return stableStringify(a) === stableStringify(b);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Drop the fields whose strip changed nothing — the caller supplied exactly
|
|
963
|
+
* the value the record already holds (objectui#3484).
|
|
964
|
+
*
|
|
965
|
+
* The server is not wrong to report those: the client DID send the key and the
|
|
966
|
+
* engine DID refuse to write it, so `droppedFields` is an accurate account of
|
|
967
|
+
* the request. But "was not saved" is only news when something was actually
|
|
968
|
+
* lost, and the console's edit form used to round-trip the whole record —
|
|
969
|
+
* including fields it had itself rendered as read-only text — so every save of
|
|
970
|
+
* a state-locked record raised a warning listing fields the user never touched
|
|
971
|
+
* and whose values never changed.
|
|
972
|
+
*
|
|
973
|
+
* A field is kept (still warned about) whenever the no-op cannot be PROVEN:
|
|
974
|
+
* the response echoed no record, or that record does not carry the key.
|
|
975
|
+
*
|
|
976
|
+
* Returns entries with empty `fields` removed; an all-no-op event list comes
|
|
977
|
+
* back empty, which suppresses the warning entirely.
|
|
978
|
+
*/
|
|
979
|
+
function withoutNoOpDrops(
|
|
980
|
+
droppedFields: DroppedFieldsEvent[],
|
|
981
|
+
sent: Record<string, unknown> | undefined | null,
|
|
982
|
+
stored: Record<string, unknown> | undefined | null,
|
|
983
|
+
): DroppedFieldsEvent[] {
|
|
984
|
+
if (!sent || !stored || typeof sent !== 'object' || typeof stored !== 'object') {
|
|
985
|
+
return droppedFields;
|
|
986
|
+
}
|
|
987
|
+
const out: DroppedFieldsEvent[] = [];
|
|
988
|
+
for (const e of droppedFields) {
|
|
989
|
+
const kept = e.fields.filter((f) => {
|
|
990
|
+
if (!Object.prototype.hasOwnProperty.call(sent, f)) return true;
|
|
991
|
+
if (!Object.prototype.hasOwnProperty.call(stored, f)) return true;
|
|
992
|
+
return !sameWireValue(
|
|
993
|
+
(sent as Record<string, unknown>)[f],
|
|
994
|
+
(stored as Record<string, unknown>)[f],
|
|
995
|
+
);
|
|
996
|
+
});
|
|
997
|
+
if (kept.length > 0) out.push({ ...e, fields: kept });
|
|
998
|
+
}
|
|
999
|
+
return out;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Resolve which object a `type='view'` metadata item belongs to.
|
|
1004
|
+
*
|
|
1005
|
+
* The metadata index is name-only, not field-typed: `GET /api/v1/meta/view`
|
|
1006
|
+
* accepts `?package=` and `?preview=draft` and nothing else (measured on
|
|
1007
|
+
* framework `packages/rest/src/rest-server.ts` — the `GET /meta/:type`
|
|
1008
|
+
* handler — and on `client.meta.getItems(type, { packageId })`). So every
|
|
1009
|
+
* reader of the view namespace enumerates `type='view'` once and narrows to
|
|
1010
|
+
* one object HERE, client-side.
|
|
1011
|
+
*
|
|
1012
|
+
* ONE spelling, one place, deliberately: {@link ObjectStackAdapter.listViews}
|
|
1013
|
+
* and {@link ObjectStackAdapter.listViewOverrides} read the same rows out of
|
|
1014
|
+
* the same namespace, and two private copies of "which object is this?" is a
|
|
1015
|
+
* drift waiting to happen — the switcher showing a view whose override the
|
|
1016
|
+
* grid cannot find, or the reverse.
|
|
1017
|
+
*
|
|
1018
|
+
* `object` is the identity field the write path stamps (and that the
|
|
1019
|
+
* framework's overlay heals onto identity-less personalization rows —
|
|
1020
|
+
* objectstack#2555); `data.object` is the config's data-provider target and
|
|
1021
|
+
* `objectName` the legacy artifact spelling.
|
|
1022
|
+
*
|
|
1023
|
+
* **Exported** since objectui#4373, for the same one-spelling reason: a writer
|
|
1024
|
+
* outside this module that holds a view BODY but not its object name (app-shell's
|
|
1025
|
+
* `MetadataService`) needs the object to name the keys
|
|
1026
|
+
* {@link ObjectStackAdapter.invalidateViewKeys} drops, and a fourth private copy
|
|
1027
|
+
* of "which object is this?" is exactly the drift this accessor exists to
|
|
1028
|
+
* prevent. Identity only — it names no cache key.
|
|
1029
|
+
*/
|
|
1030
|
+
export function viewItemObjectName(item: any): string | undefined {
|
|
1031
|
+
// Handle both bare view spec and `{list: {...}}` artifact wrapper
|
|
1032
|
+
const spec = item?.list ?? item;
|
|
1033
|
+
return spec?.data?.object ?? spec?.object ?? spec?.objectName;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Unwrap a `?state=draft` view read into its bare body, or `null` when there
|
|
1038
|
+
* is nothing pending (#4139).
|
|
1039
|
+
*
|
|
1040
|
+
* The framework answers EVERY single-item read — draft or published — in a
|
|
1041
|
+
* `{type, name, item}` envelope. `MetadataClient.get()` unwraps it at the
|
|
1042
|
+
* client boundary (objectui#4271) while `getDraft()` deliberately hands the
|
|
1043
|
+
* envelope back, so this helper stays tolerant of both: the call below reaches
|
|
1044
|
+
* it through `get()` and therefore already holds the body, and the passthrough
|
|
1045
|
+
* limb keeps it correct for an envelope arriving by any other route. An empty
|
|
1046
|
+
* body is normalized to `null` so the caller's "is this view draft-backed?"
|
|
1047
|
+
* test is a plain truthiness check. Mirrors app-shell's `unwrapDraftBody`
|
|
1048
|
+
* (ADR-0034 seam); the two live apart because the seam sits above this
|
|
1049
|
+
* adapter, not beside it.
|
|
1050
|
+
*/
|
|
1051
|
+
function unwrapViewDraft(resp: unknown): Record<string, any> | null {
|
|
1052
|
+
if (!resp || typeof resp !== 'object') return null;
|
|
1053
|
+
const env = resp as Record<string, any>;
|
|
1054
|
+
const body = 'item' in env ? env.item : env;
|
|
1055
|
+
if (!body || typeof body !== 'object') return null;
|
|
1056
|
+
// Same `{list: {...}}` artifact wrapper the published read unwraps.
|
|
1057
|
+
const spec = body.list ?? body;
|
|
1058
|
+
if (!spec || typeof spec !== 'object') return null;
|
|
1059
|
+
return Object.keys(spec).length > 0 ? (spec as Record<string, any>) : null;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* `deleteView`'s receipt shapes, RE-EXPORTED from `@object-ui/types` (#4564).
|
|
1064
|
+
*
|
|
1065
|
+
* #4479 first declared both here, because this adapter is where the widened
|
|
1066
|
+
* receipt is produced. That left the shared `DataSource.deleteView?` unable to
|
|
1067
|
+
* describe it: the dependency runs this package -> `@object-ui/types` and never
|
|
1068
|
+
* the other way, so the interface kept the narrow `{ deleted: boolean }` and a
|
|
1069
|
+
* consumer reaching this adapter THROUGH `DataSource` was handed a type with
|
|
1070
|
+
* the per-home outcomes already discarded. Nothing failed to compile — a wider
|
|
1071
|
+
* return is assignable to a narrower declaration — which is precisely why the
|
|
1072
|
+
* gap was silent until #4564 measured it.
|
|
1073
|
+
*
|
|
1074
|
+
* So the canonical declarations now live beside the interface they belong to,
|
|
1075
|
+
* in `packages/types/src/data.ts`, and this package re-exports them under the
|
|
1076
|
+
* SAME names: every importer PR #4562 left pointing at
|
|
1077
|
+
* `@object-ui/data-objectstack` keeps compiling, and now gets the very type the
|
|
1078
|
+
* shared contract speaks rather than a structural twin of it. Pinned by
|
|
1079
|
+
* `deleteViewContract.types.test.ts`, which asserts type IDENTITY — mutual
|
|
1080
|
+
* assignability would have been satisfied by a copy that was already drifting.
|
|
1081
|
+
*/
|
|
1082
|
+
export type { DeleteViewResult, ViewHomeDeleteOutcome } from '@object-ui/types';
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Read one delete receipt into a {@link ViewHomeDeleteOutcome}.
|
|
1086
|
+
*
|
|
1087
|
+
* The `deleted ?? reset ?? true` ladder is carried over verbatim from the
|
|
1088
|
+
* single-call `deleteView` this replaced, so a server that answers a shape
|
|
1089
|
+
* with neither key is read exactly as it was before (#4479): the framework
|
|
1090
|
+
* sends `reset`, the SDK's typed metadata shape names `deleted`, and the
|
|
1091
|
+
* final `true` is the "it answered 2xx and said nothing" default.
|
|
1092
|
+
*/
|
|
1093
|
+
function readViewDeleteReceipt(result: unknown): ViewHomeDeleteOutcome {
|
|
1094
|
+
const r = (result ?? undefined) as Record<string, any> | undefined;
|
|
1095
|
+
return {
|
|
1096
|
+
removed: !!(r?.deleted ?? r?.reset ?? true),
|
|
1097
|
+
...(typeof r?.reset === 'boolean' ? { reset: r.reset } : {}),
|
|
1098
|
+
...(typeof r?.message === 'string' ? { message: r.message } : {}),
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Merge a partial view patch onto the CURRENT view document.
|
|
1104
|
+
*
|
|
1105
|
+
* ADR-0005 overlay rows store the *full* view document, so a partial update is
|
|
1106
|
+
* a read-merge-write cycle and the merge must start from real current state —
|
|
1107
|
+
* merging onto `{}` yields a `{label, name, object}` fragment the server
|
|
1108
|
+
* rejects (422), which is exactly how a rename used to be lost (#4139).
|
|
1109
|
+
*
|
|
1110
|
+
* `name` is forced to the URL segment so the row key and `body.name` agree
|
|
1111
|
+
* (#2767 P1), and `object` falls back through the two spellings a stored view
|
|
1112
|
+
* may carry before defaulting to the caller's object.
|
|
1113
|
+
*/
|
|
1114
|
+
function mergeViewPatch(
|
|
1115
|
+
current: Record<string, any>,
|
|
1116
|
+
partial: Record<string, any>,
|
|
1117
|
+
viewName: string,
|
|
1118
|
+
objectName: string,
|
|
1119
|
+
): Record<string, any> {
|
|
1120
|
+
return {
|
|
1121
|
+
...current,
|
|
1122
|
+
...partial,
|
|
1123
|
+
name: viewName,
|
|
1124
|
+
object: current?.object || current?.data?.object || objectName,
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
|
|
774
1128
|
/**
|
|
775
1129
|
* ObjectStack Data Source Adapter
|
|
776
|
-
*
|
|
1130
|
+
*
|
|
777
1131
|
* Bridges the ObjectStack Client SDK with the ObjectUI DataSource interface.
|
|
778
1132
|
* This allows Object UI applications to seamlessly integrate with ObjectStack
|
|
779
1133
|
* backends while maintaining the universal DataSource abstraction.
|
|
@@ -853,6 +1207,13 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
853
1207
|
// shell can surface a toast instead of the strip passing silently.
|
|
854
1208
|
private writeWarningListeners = new Set<WriteWarningListener>();
|
|
855
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
|
+
|
|
856
1217
|
constructor(config: {
|
|
857
1218
|
baseUrl: string;
|
|
858
1219
|
token?: string;
|
|
@@ -870,6 +1231,9 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
870
1231
|
// debug() so they don't pollute the browser console. Other log levels are
|
|
871
1232
|
// forwarded to the standard console.
|
|
872
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();
|
|
873
1237
|
this.metadataCache = new MetadataCache(config.cache);
|
|
874
1238
|
this.autoReconnect = config.autoReconnect ?? true;
|
|
875
1239
|
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3;
|
|
@@ -1085,7 +1449,14 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1085
1449
|
const result: unknown = await this.client.data.find<T>(resource, queryOptions);
|
|
1086
1450
|
return this.normalizeQueryResult(result, params);
|
|
1087
1451
|
} catch (err) {
|
|
1088
|
-
|
|
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)) {
|
|
1089
1460
|
// Mark the resource so subsequent calls don't repeat the 404.
|
|
1090
1461
|
this.missingResources.add(resource);
|
|
1091
1462
|
return { data: [], total: 0 } as QueryResult<T>;
|
|
@@ -1309,13 +1680,17 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1309
1680
|
resource: string,
|
|
1310
1681
|
result: unknown,
|
|
1311
1682
|
id?: string | number,
|
|
1683
|
+
sent?: Record<string, unknown> | null,
|
|
1312
1684
|
): void {
|
|
1313
1685
|
const dropped = (result as { droppedFields?: unknown } | null | undefined)?.droppedFields;
|
|
1314
1686
|
if (!Array.isArray(dropped) || dropped.length === 0) return;
|
|
1315
|
-
const
|
|
1687
|
+
const valid = dropped.filter(
|
|
1316
1688
|
(e): e is DroppedFieldsEvent =>
|
|
1317
1689
|
!!e && typeof e === 'object' && Array.isArray((e as DroppedFieldsEvent).fields) && (e as DroppedFieldsEvent).fields.length > 0,
|
|
1318
1690
|
);
|
|
1691
|
+
// A strip that changed nothing is not news — see withoutNoOpDrops (#3484).
|
|
1692
|
+
const stored = (result as { record?: Record<string, unknown> } | null | undefined)?.record;
|
|
1693
|
+
const droppedFields = withoutNoOpDrops(valid, sent, stored);
|
|
1319
1694
|
if (droppedFields.length === 0) return;
|
|
1320
1695
|
this.emitWriteWarning({ operation, resource, ...(id !== undefined ? { id } : {}), droppedFields });
|
|
1321
1696
|
}
|
|
@@ -1338,11 +1713,25 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1338
1713
|
): void {
|
|
1339
1714
|
const dropped = (payload as { droppedFields?: unknown } | null | undefined)?.droppedFields;
|
|
1340
1715
|
if (!Array.isArray(dropped) || dropped.length === 0) return;
|
|
1716
|
+
const results = (payload as { results?: unknown[] } | null | undefined)?.results;
|
|
1341
1717
|
for (const entry of dropped) {
|
|
1342
1718
|
if (!entry || typeof entry !== 'object') continue;
|
|
1343
1719
|
const e = entry as DroppedFieldsEvent & { index?: number };
|
|
1344
1720
|
if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
|
|
1345
1721
|
const op = typeof e.index === 'number' ? operations[e.index] : undefined;
|
|
1722
|
+
// Same no-op suppression as the single-record path (#3484). The echoed
|
|
1723
|
+
// row for the originating op is the "stored" side; when the batch echoed
|
|
1724
|
+
// nothing usable, `withoutNoOpDrops` keeps every field.
|
|
1725
|
+
const stored =
|
|
1726
|
+
typeof e.index === 'number' && Array.isArray(results)
|
|
1727
|
+
? (results[e.index] as Record<string, unknown> | undefined)
|
|
1728
|
+
: undefined;
|
|
1729
|
+
const [live] = withoutNoOpDrops(
|
|
1730
|
+
[{ object: e.object, fields: e.fields, reason: e.reason }],
|
|
1731
|
+
op?.data as Record<string, unknown> | undefined,
|
|
1732
|
+
stored,
|
|
1733
|
+
);
|
|
1734
|
+
if (!live) continue;
|
|
1346
1735
|
// `delete` never drops fields; anything unexpected reads as an update,
|
|
1347
1736
|
// which is the truthful default for a batch that echoed a strip.
|
|
1348
1737
|
const operation: 'create' | 'update' = (op?.action ?? 'create') === 'create' ? 'create' : 'update';
|
|
@@ -1350,7 +1739,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1350
1739
|
operation,
|
|
1351
1740
|
resource: e.object ?? op?.object ?? '',
|
|
1352
1741
|
...(op?.id !== undefined && op?.id !== null ? { id: op.id } : {}),
|
|
1353
|
-
droppedFields: [
|
|
1742
|
+
droppedFields: [live],
|
|
1354
1743
|
});
|
|
1355
1744
|
}
|
|
1356
1745
|
}
|
|
@@ -1367,12 +1756,134 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1367
1756
|
};
|
|
1368
1757
|
}
|
|
1369
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
|
+
|
|
1370
1875
|
async create(resource: string, data: Partial<T>): Promise<T> {
|
|
1371
1876
|
await this.connect();
|
|
1372
1877
|
try {
|
|
1373
1878
|
const result = await this.client.data.create<T>(resource, data);
|
|
1374
1879
|
this.emitMutation({ type: 'create', resource, record: { ...result.record } });
|
|
1375
|
-
this.notifyDroppedFields(
|
|
1880
|
+
this.notifyDroppedFields(
|
|
1881
|
+
'create',
|
|
1882
|
+
resource,
|
|
1883
|
+
result,
|
|
1884
|
+
(result.record as { id?: string | number } | undefined)?.id,
|
|
1885
|
+
data as Record<string, unknown>,
|
|
1886
|
+
);
|
|
1376
1887
|
return result.record;
|
|
1377
1888
|
} catch (err) {
|
|
1378
1889
|
// `update` has always normalised; `create` did not, so a rejected insert
|
|
@@ -1409,7 +1920,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1409
1920
|
opts?.ifMatch ? { ifMatch: opts.ifMatch } : undefined,
|
|
1410
1921
|
);
|
|
1411
1922
|
this.emitMutation({ type: 'update', resource, id, record: { ...result.record } });
|
|
1412
|
-
this.notifyDroppedFields('update', resource, result, id);
|
|
1923
|
+
this.notifyDroppedFields('update', resource, result, id, data as Record<string, unknown>);
|
|
1413
1924
|
return result.record;
|
|
1414
1925
|
} catch (err) {
|
|
1415
1926
|
throw normaliseClientError(err);
|
|
@@ -1435,10 +1946,21 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1435
1946
|
String(id),
|
|
1436
1947
|
opts?.ifMatch ? { ifMatch: opts.ifMatch } : undefined,
|
|
1437
1948
|
);
|
|
1438
|
-
|
|
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) {
|
|
1439
1961
|
this.emitMutation({ type: 'delete', resource, id });
|
|
1440
1962
|
}
|
|
1441
|
-
return result.
|
|
1963
|
+
return result.success;
|
|
1442
1964
|
} catch (err) {
|
|
1443
1965
|
throw normaliseClientError(err);
|
|
1444
1966
|
}
|
|
@@ -2147,6 +2669,16 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2147
2669
|
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
|
|
2148
2670
|
const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText) as any;
|
|
2149
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;
|
|
2150
2682
|
throw err;
|
|
2151
2683
|
}
|
|
2152
2684
|
|
|
@@ -2520,43 +3052,121 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2520
3052
|
}
|
|
2521
3053
|
}
|
|
2522
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
|
+
|
|
2523
3102
|
/**
|
|
2524
3103
|
* Batch-fetch all persisted view overrides for an object.
|
|
2525
3104
|
*
|
|
2526
|
-
* Per-view runtime overrides (density, column widths, sort,
|
|
2527
|
-
*
|
|
2528
|
-
*
|
|
2529
|
-
*
|
|
2530
|
-
*
|
|
2531
|
-
*
|
|
2532
|
-
* returns
|
|
2533
|
-
*
|
|
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".
|
|
2534
3120
|
*
|
|
2535
|
-
*
|
|
2536
|
-
*
|
|
3121
|
+
* FAILURES REJECT — they are not answered as `{}`. An empty map is an
|
|
3122
|
+
* authoritative "this object has no overrides" (callers may trust it and
|
|
3123
|
+
* skip the per-view reads); a transport/permission failure is "I could
|
|
3124
|
+
* not tell", and reporting the two as the same value is what made
|
|
3125
|
+
* ObjectView's per-view {@link getView} fallback unreachable code. When
|
|
3126
|
+
* we cannot tell, we do not pretend we can. Rejections are not cached
|
|
3127
|
+
* (the cache stores on success only), so a transient failure does not
|
|
3128
|
+
* pin an empty answer for the TTL.
|
|
3129
|
+
*
|
|
3130
|
+
* Result is cached identically to {@link getView}, and EVERY view write —
|
|
3131
|
+
* on this adapter and above it — invalidates it through the one seam,
|
|
3132
|
+
* {@link invalidateViewKeys}. For a long time only {@link updateViewConfig}
|
|
3133
|
+
* did (objectui#4363), which left the other three adapter paths stale
|
|
3134
|
+
* for the cache's 5-minute TTL. That gap does not self-heal: the consumer
|
|
3135
|
+
* (`loadViewOverrides`, app-shell `ObjectView`) treats a RESOLVED map as
|
|
3136
|
+
* authoritative and deliberately does not re-probe per view — objectui#3774,
|
|
3137
|
+
* and correct, since re-probing reinstates the 404 flurry the batch read
|
|
3138
|
+
* exists to remove. So a stale map here is served in full, and the per-view
|
|
3139
|
+
* {@link getView} fallback that would have masked it never runs.
|
|
2537
3140
|
*
|
|
2538
3141
|
* @param objectName - Object name (e.g. 'lead')
|
|
2539
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.
|
|
2540
3145
|
*/
|
|
2541
3146
|
async listViewOverrides(objectName: string): Promise<Record<string, any>> {
|
|
2542
3147
|
await this.connect();
|
|
2543
3148
|
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
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
|
+
});
|
|
2560
3170
|
}
|
|
2561
3171
|
|
|
2562
3172
|
/**
|
|
@@ -2622,12 +3232,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2622
3232
|
viewId,
|
|
2623
3233
|
merged
|
|
2624
3234
|
);
|
|
2625
|
-
//
|
|
2626
|
-
|
|
2627
|
-
this.metadataCache.invalidate?.(cacheKey);
|
|
2628
|
-
// Also invalidate the batch override map so listViewOverrides re-fetches
|
|
2629
|
-
this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
|
|
2630
|
-
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
3235
|
+
// One seam names the key set for every writer (objectui#4373).
|
|
3236
|
+
this.invalidateViewKeys(objectName, viewId);
|
|
2631
3237
|
if (result && result.item) return result.item;
|
|
2632
3238
|
return result ?? undefined;
|
|
2633
3239
|
}
|
|
@@ -2639,8 +3245,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2639
3245
|
* match the view spec shape.
|
|
2640
3246
|
*
|
|
2641
3247
|
* Returns view spec objects with their canonical `name` as identifier.
|
|
2642
|
-
*
|
|
2643
|
-
*
|
|
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.
|
|
2644
3252
|
*/
|
|
2645
3253
|
async listViews(
|
|
2646
3254
|
objectName: string,
|
|
@@ -2679,8 +3287,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2679
3287
|
if (!v) return false;
|
|
2680
3288
|
// Handle both bare view spec and `{list: {...}}` artifact wrapper
|
|
2681
3289
|
const spec = v.list ?? v;
|
|
2682
|
-
|
|
2683
|
-
if (obj !== objectName) return false;
|
|
3290
|
+
if (viewItemObjectName(v) !== objectName) return false;
|
|
2684
3291
|
const viewKind = v.viewKind ?? spec?.viewKind;
|
|
2685
3292
|
return !(viewKind && FORM_FAMILY.has(viewKind));
|
|
2686
3293
|
}).map((v: any) => {
|
|
@@ -2758,6 +3365,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2758
3365
|
*
|
|
2759
3366
|
* Generates a snake_case name if `spec.name` is not provided by appending
|
|
2760
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).
|
|
2761
3372
|
*/
|
|
2762
3373
|
async createView(
|
|
2763
3374
|
objectName: string,
|
|
@@ -2787,16 +3398,57 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2787
3398
|
data: spec?.data || { provider: 'object', object: objectName },
|
|
2788
3399
|
};
|
|
2789
3400
|
const result: any = await this.client.meta.saveItem('view', name, fullSpec);
|
|
2790
|
-
|
|
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);
|
|
2791
3406
|
if (result && result.item) return result.item;
|
|
2792
3407
|
return fullSpec;
|
|
2793
3408
|
}
|
|
2794
3409
|
|
|
2795
3410
|
/**
|
|
2796
3411
|
* Apply a partial update to an existing overlay view. Reads the current
|
|
2797
|
-
*
|
|
2798
|
-
*
|
|
2799
|
-
*
|
|
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".
|
|
2800
3452
|
*/
|
|
2801
3453
|
async updateView(
|
|
2802
3454
|
objectName: string,
|
|
@@ -2804,42 +3456,159 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2804
3456
|
partial: Record<string, any>,
|
|
2805
3457
|
): Promise<Record<string, any> | void> {
|
|
2806
3458
|
await this.connect();
|
|
2807
|
-
|
|
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;
|
|
2808
3476
|
try {
|
|
2809
3477
|
const r: any = await this.client.meta.getItem('view', viewName);
|
|
2810
3478
|
current = (r && (r.item || r)) || {};
|
|
2811
3479
|
// Some endpoints return the bare item; others wrap as {type,name,item}
|
|
2812
3480
|
if (current?.list) current = current.list;
|
|
2813
|
-
} catch {
|
|
2814
|
-
|
|
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;
|
|
2815
3496
|
}
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
...partial,
|
|
2819
|
-
name: viewName,
|
|
2820
|
-
object: current?.object || (current as any)?.data?.object || objectName,
|
|
2821
|
-
};
|
|
3497
|
+
|
|
3498
|
+
const merged = mergeViewPatch(current, partial, viewName, objectName);
|
|
2822
3499
|
const result: any = await this.client.meta.saveItem('view', viewName, merged);
|
|
2823
|
-
this.
|
|
2824
|
-
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
|
|
3500
|
+
this.invalidateViewKeys(objectName, viewName);
|
|
2825
3501
|
if (result && result.item) return result.item;
|
|
2826
3502
|
return merged;
|
|
2827
3503
|
}
|
|
2828
3504
|
|
|
2829
3505
|
/**
|
|
2830
|
-
* Delete an overlay view
|
|
2831
|
-
*
|
|
2832
|
-
*
|
|
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.
|
|
2833
3574
|
*/
|
|
2834
3575
|
async deleteView(
|
|
2835
3576
|
objectName: string,
|
|
2836
3577
|
viewName: string,
|
|
2837
|
-
): Promise<
|
|
3578
|
+
): Promise<DeleteViewResult> {
|
|
2838
3579
|
await this.connect();
|
|
2839
|
-
const
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
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
|
+
}
|
|
2843
3612
|
}
|
|
2844
3613
|
|
|
2845
3614
|
|
|
@@ -2868,6 +3637,58 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
2868
3637
|
}
|
|
2869
3638
|
}
|
|
2870
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
|
+
|
|
2871
3692
|
/**
|
|
2872
3693
|
* Get a page definition from ObjectStack.
|
|
2873
3694
|
* Uses the metadata API to fetch page layouts.
|
|
@@ -3096,36 +3917,70 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
3096
3917
|
* silently returning wrong numbers.
|
|
3097
3918
|
*
|
|
3098
3919
|
* @param dataset - An inline dataset definition (draft) OR a saved dataset name.
|
|
3099
|
-
* @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`.
|
|
3100
3931
|
*/
|
|
3101
3932
|
async queryDataset(
|
|
3102
3933
|
dataset: Record<string, unknown> | string,
|
|
3103
|
-
selection:
|
|
3104
|
-
dimensions?: string[];
|
|
3105
|
-
measures: string[];
|
|
3106
|
-
runtimeFilter?: Record<string, unknown>;
|
|
3107
|
-
timeDimensions?: unknown[];
|
|
3108
|
-
compareTo?: { kind: 'previousPeriod' | 'previousYear'; dimension: string };
|
|
3109
|
-
order?: Record<string, 'asc' | 'desc'>;
|
|
3110
|
-
limit?: number;
|
|
3111
|
-
offset?: number;
|
|
3112
|
-
timezone?: string;
|
|
3113
|
-
/** Marginal-aggregate groupings (e.g. `[rows, [colDim], []]`) — server
|
|
3114
|
-
* computes each subtotal with the measure's TRUE aggregate (never client
|
|
3115
|
-
* re-derived). `[]` is the grand total. */
|
|
3116
|
-
totals?: { groupings: string[][] };
|
|
3117
|
-
},
|
|
3934
|
+
selection: DatasetSelection,
|
|
3118
3935
|
): Promise<{
|
|
3119
3936
|
rows: Array<Record<string, unknown>>;
|
|
3120
|
-
/**
|
|
3121
|
-
*
|
|
3122
|
-
|
|
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]>;
|
|
3123
3957
|
/** ADR-0021 D2 drill-through: the dataset's base object (records to drill into). */
|
|
3124
3958
|
object?: string;
|
|
3125
3959
|
/** Drillable dimension NAME → underlying object FIELD name. */
|
|
3126
3960
|
dimensionFields?: Record<string, string>;
|
|
3127
3961
|
/** Raw grouped values per row (aligned to `rows` by index) for drill filters. */
|
|
3128
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>>;
|
|
3129
3984
|
/** Server-computed marginal aggregates, one entry per requested grouping. */
|
|
3130
3985
|
totals?: Array<{ dimensions: string[]; rows: Array<Record<string, unknown>> }>;
|
|
3131
3986
|
}> {
|
|
@@ -3192,8 +4047,16 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
3192
4047
|
? ((data as any).dimensionFields as Record<string, string>)
|
|
3193
4048
|
: undefined;
|
|
3194
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;
|
|
3195
4058
|
const totals = Array.isArray((data as any)?.totals) ? (data as any).totals : undefined;
|
|
3196
|
-
return { rows, fields, object, dimensionFields, drillRawRows, totals };
|
|
4059
|
+
return { rows, fields, object, dimensionFields, drillRawRows, drillRanges, totals };
|
|
3197
4060
|
}
|
|
3198
4061
|
|
|
3199
4062
|
/** Client-side aggregation fallback */
|
|
@@ -3527,9 +4390,10 @@ export {
|
|
|
3527
4390
|
export type { MetadataCacheStats } from './cache/MetadataCache';
|
|
3528
4391
|
|
|
3529
4392
|
// v3.0.0 Deep Integration modules
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
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.)
|
|
3533
4397
|
export { validatePluginContract, generateContractManifest } from './contracts';
|
|
3534
4398
|
|
|
3535
4399
|
// User-scoped persistence adapter (favorites / recent items / …)
|
|
@@ -3547,8 +4411,11 @@ export type { IntegrationConfig, IntegrationTrigger, IntegrationProvider, SlackI
|
|
|
3547
4411
|
// Used by plugin-designer to back the Setup-app Object Manager and Field
|
|
3548
4412
|
// Designer surfaces; kept separate from ObjectStackAdapter so callers
|
|
3549
4413
|
// can use it without the full data-source surface.
|
|
3550
|
-
export { MetadataClient } from './metadata-client';
|
|
4414
|
+
export { MetadataClient, readSaveAdvisories } from './metadata-client';
|
|
3551
4415
|
export type {
|
|
4416
|
+
RuntimeAuthoringIssue,
|
|
4417
|
+
MetadataSaveAdvisoryEvent,
|
|
4418
|
+
MetadataSaveAdvisoryListener,
|
|
3552
4419
|
MetadataClientConfig,
|
|
3553
4420
|
MetadataListOptions,
|
|
3554
4421
|
MetadataDraftHeader,
|