@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/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
- * Build a Logger compatible with @objectstack/client that demotes expected
644
- * 404 noise to console.debug. The client logs every non-2xx response with
645
- * `logger.error("HTTP request failed", undefined, { status, error })`, but
646
- * 404s on optional collections (sys_presence, sys_activity, …) are part of
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(`[ObjectStack] ${message} (suppressed expected 404)`, meta);
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
- if (is404Error(err)) {
1452
+ // An `enable`-block denial is NOT a missing collection. The object
1453
+ // exists and the server is deliberately refusing to expose it, forever
1454
+ // and for everyone — degrading that to an empty result set tells the
1455
+ // user "you have no records" about a page that can never hold any
1456
+ // (objectui#4408). Rethrow so the surface can say what happened; the
1457
+ // `missingResources` memo must not absorb it either, or the very first
1458
+ // denial would silently pin every later call to empty.
1459
+ if (!isApiAccessDeniedError(err) && is404Error(err)) {
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 droppedFields = dropped.filter(
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: [{ object: e.object, fields: e.fields, reason: e.reason }],
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('create', resource, result, (result.record as { id?: string | number } | undefined)?.id);
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
- if (result.deleted) {
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.deleted;
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, …) are
2527
- * stored in the metadata registry under key `<objectName>/<viewName>`.
2528
- * Loading them per-view fires N HTTP GETs that return 404 for views
2529
- * the user has never customized — generates console noise on every
2530
- * page load. This batch method performs a single
2531
- * `GET /api/v1/meta/<objectName>` (returns `{type, items}`) and
2532
- * returns a `{viewName: override}` map. Returns an empty map if the
2533
- * server doesn't expose the listing or returns no items.
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
- * Result is cached identically to {@link getView}; saving a view via
2536
- * {@link updateViewConfig} invalidates the cache.
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
- try {
2545
- const cacheKey = `view-overrides:${objectName}`;
2546
- return await this.metadataCache.get(cacheKey, async () => {
2547
- const result: any = await this.client.meta.getItems(objectName);
2548
- const items: any[] = Array.isArray(result?.items) ? result.items : [];
2549
- const out: Record<string, any> = {};
2550
- for (const it of items) {
2551
- if (!it || typeof it !== 'object') continue;
2552
- const key = it.name ?? it.id ?? it._name;
2553
- if (typeof key === 'string' && key) out[key] = it;
2554
- }
2555
- return out;
2556
- });
2557
- } catch {
2558
- return {};
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
- // Invalidate cached read so next getView reflects the change
2626
- const cacheKey = `view:${objectName}:${viewId}`;
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
- * Filters by `data.object === objectName` (or top-level `object`)
2643
- * client-side because the metadata index is name-only, not field-typed.
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
- const obj = spec?.data?.object ?? spec?.object ?? spec?.objectName;
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
- this.metadataCache.invalidate?.(`views:${objectName}`);
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
- * overlay (or seeds from artifact), merges, and writes back. ADR-0005
2798
- * overlay rows store the *full* view document, so partial updates require
2799
- * a read-merge-write cycle.
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
- let current: any = {};
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
- // Treat missing as create-equivalent
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
- const merged = {
2817
- ...current,
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.metadataCache.invalidate?.(`views:${objectName}`);
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 (reset to artifact default if one exists, or
2831
- * remove entirely if it was a user-created view). Routes to
2832
- * `DELETE /api/v1/meta/view/:name`.
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<{ deleted: boolean }> {
3578
+ ): Promise<DeleteViewResult> {
2838
3579
  await this.connect();
2839
- const result: any = await this.client.meta.deleteItem('view', viewName);
2840
- this.metadataCache.invalidate?.(`views:${objectName}`);
2841
- this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
2842
- return { deleted: !!(result?.deleted ?? result?.reset ?? true) };
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 - Dimension/measure names to project + runtime directives.
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
- /** Column metadata: a display `label` (dimensions and measures), and a
3121
- * measure's numeral `format` + declared `currency` for value formatting. */
3122
- fields: Array<{ name: string; type: string; label?: string; format?: string; currency?: string }>;
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
- export { CloudOperations } from './cloud';
3531
- export type { CloudDeploymentConfig, CloudHostingConfig, CloudMarketplaceEntry } from './cloud';
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,