@object-ui/data-objectstack 17.3.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,
@@ -830,6 +999,132 @@ function withoutNoOpDrops(
830
999
  return out;
831
1000
  }
832
1001
 
1002
+ /**
1003
+ * Resolve which object a `type='view'` metadata item belongs to.
1004
+ *
1005
+ * The metadata index is name-only, not field-typed: `GET /api/v1/meta/view`
1006
+ * accepts `?package=` and `?preview=draft` and nothing else (measured on
1007
+ * framework `packages/rest/src/rest-server.ts` — the `GET /meta/:type`
1008
+ * handler — and on `client.meta.getItems(type, { packageId })`). So every
1009
+ * reader of the view namespace enumerates `type='view'` once and narrows to
1010
+ * one object HERE, client-side.
1011
+ *
1012
+ * ONE spelling, one place, deliberately: {@link ObjectStackAdapter.listViews}
1013
+ * and {@link ObjectStackAdapter.listViewOverrides} read the same rows out of
1014
+ * the same namespace, and two private copies of "which object is this?" is a
1015
+ * drift waiting to happen — the switcher showing a view whose override the
1016
+ * grid cannot find, or the reverse.
1017
+ *
1018
+ * `object` is the identity field the write path stamps (and that the
1019
+ * framework's overlay heals onto identity-less personalization rows —
1020
+ * objectstack#2555); `data.object` is the config's data-provider target and
1021
+ * `objectName` the legacy artifact spelling.
1022
+ *
1023
+ * **Exported** since objectui#4373, for the same one-spelling reason: a writer
1024
+ * outside this module that holds a view BODY but not its object name (app-shell's
1025
+ * `MetadataService`) needs the object to name the keys
1026
+ * {@link ObjectStackAdapter.invalidateViewKeys} drops, and a fourth private copy
1027
+ * of "which object is this?" is exactly the drift this accessor exists to
1028
+ * prevent. Identity only — it names no cache key.
1029
+ */
1030
+ export function viewItemObjectName(item: any): string | undefined {
1031
+ // Handle both bare view spec and `{list: {...}}` artifact wrapper
1032
+ const spec = item?.list ?? item;
1033
+ return spec?.data?.object ?? spec?.object ?? spec?.objectName;
1034
+ }
1035
+
1036
+ /**
1037
+ * Unwrap a `?state=draft` view read into its bare body, or `null` when there
1038
+ * is nothing pending (#4139).
1039
+ *
1040
+ * The framework answers EVERY single-item read — draft or published — in a
1041
+ * `{type, name, item}` envelope. `MetadataClient.get()` unwraps it at the
1042
+ * client boundary (objectui#4271) while `getDraft()` deliberately hands the
1043
+ * envelope back, so this helper stays tolerant of both: the call below reaches
1044
+ * it through `get()` and therefore already holds the body, and the passthrough
1045
+ * limb keeps it correct for an envelope arriving by any other route. An empty
1046
+ * body is normalized to `null` so the caller's "is this view draft-backed?"
1047
+ * test is a plain truthiness check. Mirrors app-shell's `unwrapDraftBody`
1048
+ * (ADR-0034 seam); the two live apart because the seam sits above this
1049
+ * adapter, not beside it.
1050
+ */
1051
+ function unwrapViewDraft(resp: unknown): Record<string, any> | null {
1052
+ if (!resp || typeof resp !== 'object') return null;
1053
+ const env = resp as Record<string, any>;
1054
+ const body = 'item' in env ? env.item : env;
1055
+ if (!body || typeof body !== 'object') return null;
1056
+ // Same `{list: {...}}` artifact wrapper the published read unwraps.
1057
+ const spec = body.list ?? body;
1058
+ if (!spec || typeof spec !== 'object') return null;
1059
+ return Object.keys(spec).length > 0 ? (spec as Record<string, any>) : null;
1060
+ }
1061
+
1062
+ /**
1063
+ * `deleteView`'s receipt shapes, RE-EXPORTED from `@object-ui/types` (#4564).
1064
+ *
1065
+ * #4479 first declared both here, because this adapter is where the widened
1066
+ * receipt is produced. That left the shared `DataSource.deleteView?` unable to
1067
+ * describe it: the dependency runs this package -> `@object-ui/types` and never
1068
+ * the other way, so the interface kept the narrow `{ deleted: boolean }` and a
1069
+ * consumer reaching this adapter THROUGH `DataSource` was handed a type with
1070
+ * the per-home outcomes already discarded. Nothing failed to compile — a wider
1071
+ * return is assignable to a narrower declaration — which is precisely why the
1072
+ * gap was silent until #4564 measured it.
1073
+ *
1074
+ * So the canonical declarations now live beside the interface they belong to,
1075
+ * in `packages/types/src/data.ts`, and this package re-exports them under the
1076
+ * SAME names: every importer PR #4562 left pointing at
1077
+ * `@object-ui/data-objectstack` keeps compiling, and now gets the very type the
1078
+ * shared contract speaks rather than a structural twin of it. Pinned by
1079
+ * `deleteViewContract.types.test.ts`, which asserts type IDENTITY — mutual
1080
+ * assignability would have been satisfied by a copy that was already drifting.
1081
+ */
1082
+ export type { DeleteViewResult, ViewHomeDeleteOutcome } from '@object-ui/types';
1083
+
1084
+ /**
1085
+ * Read one delete receipt into a {@link ViewHomeDeleteOutcome}.
1086
+ *
1087
+ * The `deleted ?? reset ?? true` ladder is carried over verbatim from the
1088
+ * single-call `deleteView` this replaced, so a server that answers a shape
1089
+ * with neither key is read exactly as it was before (#4479): the framework
1090
+ * sends `reset`, the SDK's typed metadata shape names `deleted`, and the
1091
+ * final `true` is the "it answered 2xx and said nothing" default.
1092
+ */
1093
+ function readViewDeleteReceipt(result: unknown): ViewHomeDeleteOutcome {
1094
+ const r = (result ?? undefined) as Record<string, any> | undefined;
1095
+ return {
1096
+ removed: !!(r?.deleted ?? r?.reset ?? true),
1097
+ ...(typeof r?.reset === 'boolean' ? { reset: r.reset } : {}),
1098
+ ...(typeof r?.message === 'string' ? { message: r.message } : {}),
1099
+ };
1100
+ }
1101
+
1102
+ /**
1103
+ * Merge a partial view patch onto the CURRENT view document.
1104
+ *
1105
+ * ADR-0005 overlay rows store the *full* view document, so a partial update is
1106
+ * a read-merge-write cycle and the merge must start from real current state —
1107
+ * merging onto `{}` yields a `{label, name, object}` fragment the server
1108
+ * rejects (422), which is exactly how a rename used to be lost (#4139).
1109
+ *
1110
+ * `name` is forced to the URL segment so the row key and `body.name` agree
1111
+ * (#2767 P1), and `object` falls back through the two spellings a stored view
1112
+ * may carry before defaulting to the caller's object.
1113
+ */
1114
+ function mergeViewPatch(
1115
+ current: Record<string, any>,
1116
+ partial: Record<string, any>,
1117
+ viewName: string,
1118
+ objectName: string,
1119
+ ): Record<string, any> {
1120
+ return {
1121
+ ...current,
1122
+ ...partial,
1123
+ name: viewName,
1124
+ object: current?.object || current?.data?.object || objectName,
1125
+ };
1126
+ }
1127
+
833
1128
  /**
834
1129
  * ObjectStack Data Source Adapter
835
1130
  *
@@ -912,6 +1207,13 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
912
1207
  // shell can surface a toast instead of the strip passing silently.
913
1208
  private writeWarningListeners = new Set<WriteWarningListener>();
914
1209
 
1210
+ // Subscribers registered via onSaveAdvisory(). Emitted after a metadata save
1211
+ // through THIS adapter's `ObjectStackClient` whose 200 carried a non-empty
1212
+ // `advisories` array (#4237; backend objectstack#7435). Sibling of the set
1213
+ // above in every respect except which door produced the event: that one is
1214
+ // record CRUD, this one is the metadata save door.
1215
+ private saveAdvisoryListeners = new Set<MetadataSaveAdvisoryListener>();
1216
+
915
1217
  constructor(config: {
916
1218
  baseUrl: string;
917
1219
  token?: string;
@@ -929,6 +1231,9 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
929
1231
  // debug() so they don't pollute the browser console. Other log levels are
930
1232
  // forwarded to the standard console.
931
1233
  this.client = new ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
1234
+ // #4237 — one emitter for every metadata save this adapter's client makes,
1235
+ // installed the moment the client exists so no save can precede it.
1236
+ this.installSaveAdvisoryInterceptor();
932
1237
  this.metadataCache = new MetadataCache(config.cache);
933
1238
  this.autoReconnect = config.autoReconnect ?? true;
934
1239
  this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3;
@@ -1144,7 +1449,14 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1144
1449
  const result: unknown = await this.client.data.find<T>(resource, queryOptions);
1145
1450
  return this.normalizeQueryResult(result, params);
1146
1451
  } catch (err) {
1147
- 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)) {
1148
1460
  // Mark the resource so subsequent calls don't repeat the 404.
1149
1461
  this.missingResources.add(resource);
1150
1462
  return { data: [], total: 0 } as QueryResult<T>;
@@ -1444,6 +1756,122 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1444
1756
  };
1445
1757
  }
1446
1758
 
1759
+ /**
1760
+ * Subscribe to metadata save-advisory events — the runtime authoring gate's
1761
+ * advisory findings on a save that SUCCEEDED (#4237; backend
1762
+ * objectstack#7435). Returns an unsubscribe function.
1763
+ *
1764
+ * Deliberately the same seam as {@link onWriteWarning} (#3431/#3455), which
1765
+ * is what {@link MetadataSaveAdvisoryEvent}'s own declaration already said it
1766
+ * was modelled on. It is a SIBLING of that channel rather than a second
1767
+ * payload pushed down it: `WriteWarningEvent` is a closed shape whose
1768
+ * `droppedFields` is required and means "fields the write legally stripped",
1769
+ * so carrying advisories on it would either force every existing
1770
+ * `onWriteWarning` consumer to grow a branch or make the event lie about what
1771
+ * happened. The seam's SHAPE is what is reused here — a long-lived instance
1772
+ * with a `subscribe → unsubscribe` registration that `AdapterProvider` wires
1773
+ * once — not its event type.
1774
+ *
1775
+ * Why here and not on the config, which is how the other client class does it
1776
+ * (#4133/#4236): `MetadataClient` is minted per component by
1777
+ * `useMetadataClient`, so it has no instance to subscribe to and its sink
1778
+ * rides the factory. `ObjectStackAdapter` is the opposite — one long-lived
1779
+ * instance per app, already carrying this exact subscription pattern.
1780
+ */
1781
+ onSaveAdvisory(callback: MetadataSaveAdvisoryListener): () => void {
1782
+ this.saveAdvisoryListeners.add(callback);
1783
+ return () => {
1784
+ this.saveAdvisoryListeners.delete(callback);
1785
+ };
1786
+ }
1787
+
1788
+ /**
1789
+ * Notify all save-advisory subscribers. Isolated exactly like
1790
+ * {@link emitWriteWarning}: a throwing listener must neither break the save
1791
+ * nor starve the others.
1792
+ */
1793
+ private emitSaveAdvisory(event: MetadataSaveAdvisoryEvent): void {
1794
+ for (const listener of this.saveAdvisoryListeners) {
1795
+ try {
1796
+ listener(event);
1797
+ } catch (err) {
1798
+ console.warn('ObjectStackAdapter: save-advisory listener error', err);
1799
+ }
1800
+ }
1801
+ }
1802
+
1803
+ /**
1804
+ * Install the ONE emitter for the metadata save door (#4237).
1805
+ *
1806
+ * ## Why this seam, and what it covers
1807
+ *
1808
+ * `ObjectStackClient.meta.saveItem` is the second client class that writes
1809
+ * through `PUT /api/v1/meta/:type/:name`, and every one of its callers reaches
1810
+ * it through an adapter this class constructed — the four inside this file
1811
+ * (`updateViewConfig`, the two view paths, `updateDashboard`) via
1812
+ * `this.client`, and every caller outside it via {@link getClient}, which
1813
+ * hands back this same instance: `MetadataService` (app-shell, five saves),
1814
+ * `useNavigationSync`, and plugin-designer's Create/EditAppPage. Wrapping the
1815
+ * method once here therefore covers all of them WITHOUT a per-site edit, which
1816
+ * is the whole point — a toast copied into a dozen call sites is the shape
1817
+ * #4133 rejected for the other client class and it is no better here.
1818
+ *
1819
+ * `meta` is an own, writable property assigned per instance in the SDK's
1820
+ * constructor (`this.meta = { … }`), and the client this adapter builds is
1821
+ * never shared, so the wrap is bounded to an object this adapter owns for its
1822
+ * whole lifetime. It is not a prototype or global patch.
1823
+ *
1824
+ * ## Response shape — measured, not assumed
1825
+ *
1826
+ * The two client classes' envelopes coincide at the top level, which is what
1827
+ * makes `readSaveAdvisories` reusable unchanged across both. `SaveMetaItem-
1828
+ * ResponseSchema` puts `advisories` at the body's top level next to
1829
+ * `success` / `version` / `seq` / `state`, and the SDK's `unwrapResponse`
1830
+ * strips its `{ success, data }` envelope only when the body actually HAS a
1831
+ * `data` key — this body does not, so it is returned verbatim. So the same
1832
+ * reader that `MetadataClient.save` uses reads this response correctly, and
1833
+ * the pins in `onSaveAdvisory.test.ts` drive a real SDK client through a fake
1834
+ * `fetch` rather than stubbing `meta`, so that continues to be measured.
1835
+ *
1836
+ * ## Draft-door honesty (D1)
1837
+ *
1838
+ * Drafts are NEVER gated: the framework returns at its D1 early-return
1839
+ * (`if (args.state !== 'active') return null`) before running a rule, so a
1840
+ * draft save produces no findings to withhold. This client class has no draft
1841
+ * door at all to worry about — the SDK's `saveItem(type, name, item)` takes no
1842
+ * mode and always writes the active door, which is exactly why the gate DOES
1843
+ * run for its callers. `mode` on the emitted event is therefore derived from
1844
+ * the response's own `state` rather than from a request-side flag that does
1845
+ * not exist here: `'draft'` when the server says the row landed as a draft,
1846
+ * `'publish'` otherwise. That keeps the event truthful about which door it
1847
+ * came through instead of hard-coding one.
1848
+ */
1849
+ private installSaveAdvisoryInterceptor(): void {
1850
+ const meta = this.client.meta;
1851
+ const original = meta.saveItem.bind(meta);
1852
+ meta.saveItem = async (type: string, name: string, item: any) => {
1853
+ const result = await original(type, name, item);
1854
+ // Everything below is best-effort by construction: the row is already
1855
+ // committed server-side, so nothing the advisory channel does may change
1856
+ // what this call returns or whether it throws.
1857
+ try {
1858
+ const advisories = readSaveAdvisories(result);
1859
+ if (advisories.length > 0) {
1860
+ this.emitSaveAdvisory({
1861
+ type,
1862
+ name,
1863
+ mode: (result as { state?: string } | null | undefined)?.state === 'draft' ? 'draft' : 'publish',
1864
+ advisories,
1865
+ });
1866
+ }
1867
+ } catch (err) {
1868
+ /* an advisory must never turn a committed save into a thrown error */
1869
+ console.warn('ObjectStackAdapter: save-advisory read error', err);
1870
+ }
1871
+ return result;
1872
+ };
1873
+ }
1874
+
1447
1875
  async create(resource: string, data: Partial<T>): Promise<T> {
1448
1876
  await this.connect();
1449
1877
  try {
@@ -1518,10 +1946,21 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1518
1946
  String(id),
1519
1947
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : undefined,
1520
1948
  );
1521
- 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) {
1522
1961
  this.emitMutation({ type: 'delete', resource, id });
1523
1962
  }
1524
- return result.deleted;
1963
+ return result.success;
1525
1964
  } catch (err) {
1526
1965
  throw normaliseClientError(err);
1527
1966
  }
@@ -2230,6 +2669,16 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
2230
2669
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
2231
2670
  const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText) as any;
2232
2671
  err.status = res.status;
2672
+ // Carry the ADR-0112 envelope, not just the status. This branch bypasses
2673
+ // `@objectstack/client` — whose fetch wrapper stamps `code`/`httpStatus`
2674
+ // from the error body — so dropping the code here made THIS path (the one
2675
+ // taken whenever the view expands a lookup or runs a search) the only list
2676
+ // fetch on which a semantic denial arrives indistinguishable from any
2677
+ // other 404/405, leaving the surface nothing to discriminate on
2678
+ // (objectui#4408). Same precedence as the client's wrapper: the top-level
2679
+ // `code` first, then the nested envelope's.
2680
+ err.code = errorBody?.code ?? errorBody?.error?.code;
2681
+ err.httpStatus = res.status;
2233
2682
  throw err;
2234
2683
  }
2235
2684
 
@@ -2603,43 +3052,121 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
2603
3052
  }
2604
3053
  }
2605
3054
 
3055
+ /**
3056
+ * **The one place that knows which cache keys a view-row write invalidates.**
3057
+ *
3058
+ * Two reads are cached under view-shaped keys, and both go stale when any
3059
+ * writer touches a view row for `objectName`:
3060
+ *
3061
+ * | reader | key |
3062
+ * |----------------------------|----------------------------|
3063
+ * | {@link getView} | `view:{object}:{viewName}` |
3064
+ * | {@link listViewOverrides} | `view-overrides:{object}` |
3065
+ *
3066
+ * Every writer routes here rather than restating that pair. The repo has now
3067
+ * paid twice for restatement: objectui#3778 removed five copies of a key
3068
+ * (`views:{object}`) that no reader had ever populated, and objectui#4363
3069
+ * fixed four copies that named only half the live set — and objectui#4373 is
3070
+ * the measured proof that the failure recurs by default, because the console's
3071
+ * real create/publish flow writes these same rows through the ADR-0034
3072
+ * metadata seam and never learned the key list at all. A pin suite can only
3073
+ * guard the writers that exist; a mandatory seam makes the next writer
3074
+ * structurally unable to forget.
3075
+ *
3076
+ * The rule is uniform per WRITE, not per branch: a write to a view row drops
3077
+ * both keys, in that order. Draft-addressed writes over-invalidate on purpose
3078
+ * — both readers enumerate PUBLISHED rows, so a draft write stales neither —
3079
+ * because the costs are not symmetric. An unnecessary invalidation costs one
3080
+ * refetch; a missed one costs up to the cache's 5-minute TTL of stale
3081
+ * overrides, and "which half am I in?" is not a question a future writer
3082
+ * should have to re-answer to stay correct.
3083
+ *
3084
+ * Public because the writers are not all in this class: app-shell's ADR-0034
3085
+ * seam (`runtime-metadata-persistence.ts`) and `MetadataService` write the
3086
+ * same rows through `client.meta` / the `/meta` draft-publish API, and they
3087
+ * reach this adapter as the `dataSource` they were already handed.
3088
+ *
3089
+ * @param objectName - Object the view belongs to (the `{object}` in both keys)
3090
+ * @param viewName - The view's canonical `name` — the identity
3091
+ * {@link getView} reads back by and {@link listViewOverrides} keys its map
3092
+ * with. Callers that hold a qualified `<object>.<key>` name pass it as-is:
3093
+ * it is the row key, not a path to be split.
3094
+ */
3095
+ invalidateViewKeys(objectName: string, viewName: string): void {
3096
+ // Ordered per-view key first, then the object's map — the order the pin
3097
+ // suite records, so a reordering is a visible decision rather than drift.
3098
+ this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
3099
+ this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
3100
+ }
3101
+
2606
3102
  /**
2607
3103
  * Batch-fetch all persisted view overrides for an object.
2608
3104
  *
2609
- * Per-view runtime overrides (density, column widths, sort, …) are
2610
- * stored in the metadata registry under key `<objectName>/<viewName>`.
2611
- * Loading them per-view fires N HTTP GETs that return 404 for views
2612
- * the user has never customized — generates console noise on every
2613
- * page load. This batch method performs a single
2614
- * `GET /api/v1/meta/<objectName>` (returns `{type, items}`) and
2615
- * returns a `{viewName: override}` map. Returns an empty map if the
2616
- * server doesn't expose the listing or returns no items.
3105
+ * Per-view runtime overrides (density, column widths, sort, hidden
3106
+ * columns, inlineEdit …) live in the SAME metadata namespace the
3107
+ * write path uses: `type='view'`, `name=<viewId>` (see
3108
+ * {@link updateViewConfig}). Loading them per-view fires N HTTP GETs
3109
+ * that 404 for every view the user never customized — console noise on
3110
+ * every page load. This batch method performs a single
3111
+ * `GET /api/v1/meta/view` (returns `{type, items}`) and narrows the
3112
+ * result to `objectName` client-side, exactly as {@link listViews}
3113
+ * does over the same rows (shared accessor: {@link viewItemObjectName}).
3114
+ *
3115
+ * objectui#3774 — this used to enumerate `GET /api/v1/meta/<objectName>`,
3116
+ * putting the OBJECT name in the metadata TYPE slot. That key space is
3117
+ * disjoint from the one the write path lands in, so the batch map came
3118
+ * back empty for every object, forever, and every saved personalization
3119
+ * read back as "setting didn't save".
3120
+ *
3121
+ * FAILURES REJECT — they are not answered as `{}`. An empty map is an
3122
+ * authoritative "this object has no overrides" (callers may trust it and
3123
+ * skip the per-view reads); a transport/permission failure is "I could
3124
+ * not tell", and reporting the two as the same value is what made
3125
+ * ObjectView's per-view {@link getView} fallback unreachable code. When
3126
+ * we cannot tell, we do not pretend we can. Rejections are not cached
3127
+ * (the cache stores on success only), so a transient failure does not
3128
+ * pin an empty answer for the TTL.
2617
3129
  *
2618
- * Result is cached identically to {@link getView}; saving a view via
2619
- * {@link updateViewConfig} invalidates the cache.
3130
+ * Result is cached identically to {@link getView}, and EVERY view write —
3131
+ * on this adapter and above it — invalidates it through the one seam,
3132
+ * {@link invalidateViewKeys}. For a long time only {@link updateViewConfig}
3133
+ * did (objectui#4363), which left the other three adapter paths stale
3134
+ * for the cache's 5-minute TTL. That gap does not self-heal: the consumer
3135
+ * (`loadViewOverrides`, app-shell `ObjectView`) treats a RESOLVED map as
3136
+ * authoritative and deliberately does not re-probe per view — objectui#3774,
3137
+ * and correct, since re-probing reinstates the 404 flurry the batch read
3138
+ * exists to remove. So a stale map here is served in full, and the per-view
3139
+ * {@link getView} fallback that would have masked it never runs.
2620
3140
  *
2621
3141
  * @param objectName - Object name (e.g. 'lead')
2622
3142
  * @returns Map keyed by view name with the persisted override config
3143
+ * @throws whatever the metadata transport throws — callers that have a
3144
+ * per-view fallback should catch and use it.
2623
3145
  */
2624
3146
  async listViewOverrides(objectName: string): Promise<Record<string, any>> {
2625
3147
  await this.connect();
2626
3148
 
2627
- try {
2628
- const cacheKey = `view-overrides:${objectName}`;
2629
- return await this.metadataCache.get(cacheKey, async () => {
2630
- const result: any = await this.client.meta.getItems(objectName);
2631
- const items: any[] = Array.isArray(result?.items) ? result.items : [];
2632
- const out: Record<string, any> = {};
2633
- for (const it of items) {
2634
- if (!it || typeof it !== 'object') continue;
2635
- const key = it.name ?? it.id ?? it._name;
2636
- if (typeof key === 'string' && key) out[key] = it;
2637
- }
2638
- return out;
2639
- });
2640
- } catch {
2641
- return {};
2642
- }
3149
+ const cacheKey = `view-overrides:${objectName}`;
3150
+ return await this.metadataCache.get(cacheKey, async () => {
3151
+ const result: any = await this.client.meta.getItems('view');
3152
+ const items: any[] = Array.isArray(result?.items)
3153
+ ? result.items
3154
+ : Array.isArray(result) ? result : [];
3155
+ const out: Record<string, any> = {};
3156
+ for (const it of items) {
3157
+ if (!it || typeof it !== 'object') continue;
3158
+ if (viewItemObjectName(it) !== objectName) continue;
3159
+ // Keyed by the item's canonical `name` — the SAME identity
3160
+ // `updateViewConfig` writes under and `getView` reads back by, which
3161
+ // is what makes this a drop-in substitute for the per-view fetch.
3162
+ // No `?? id ?? _name` alias chain: those are not view identities on
3163
+ // any route (`/meta/view/:name` is name-addressed), and a batch map
3164
+ // keyed by something no caller can ask for is dead weight.
3165
+ const key = it.name;
3166
+ if (typeof key === 'string' && key) out[key] = it;
3167
+ }
3168
+ return out;
3169
+ });
2643
3170
  }
2644
3171
 
2645
3172
  /**
@@ -2705,12 +3232,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
2705
3232
  viewId,
2706
3233
  merged
2707
3234
  );
2708
- // Invalidate cached read so next getView reflects the change
2709
- const cacheKey = `view:${objectName}:${viewId}`;
2710
- this.metadataCache.invalidate?.(cacheKey);
2711
- // Also invalidate the batch override map so listViewOverrides re-fetches
2712
- this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
2713
- this.metadataCache.invalidate?.(`views:${objectName}`);
3235
+ // One seam names the key set for every writer (objectui#4373).
3236
+ this.invalidateViewKeys(objectName, viewId);
2714
3237
  if (result && result.item) return result.item;
2715
3238
  return result ?? undefined;
2716
3239
  }
@@ -2722,8 +3245,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
2722
3245
  * match the view spec shape.
2723
3246
  *
2724
3247
  * Returns view spec objects with their canonical `name` as identifier.
2725
- * Filters by `data.object === objectName` (or top-level `object`)
2726
- * 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.
2727
3252
  */
2728
3253
  async listViews(
2729
3254
  objectName: string,
@@ -2762,8 +3287,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
2762
3287
  if (!v) return false;
2763
3288
  // Handle both bare view spec and `{list: {...}}` artifact wrapper
2764
3289
  const spec = v.list ?? v;
2765
- const obj = spec?.data?.object ?? spec?.object ?? spec?.objectName;
2766
- if (obj !== objectName) return false;
3290
+ if (viewItemObjectName(v) !== objectName) return false;
2767
3291
  const viewKind = v.viewKind ?? spec?.viewKind;
2768
3292
  return !(viewKind && FORM_FAMILY.has(viewKind));
2769
3293
  }).map((v: any) => {
@@ -2841,6 +3365,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
2841
3365
  *
2842
3366
  * Generates a snake_case name if `spec.name` is not provided by appending
2843
3367
  * a short timestamp suffix to the source-name hint.
3368
+ *
3369
+ * Invalidates through {@link invalidateViewKeys}, like every other writer —
3370
+ * see {@link listViewOverrides} for why the batch map is the one that cannot
3371
+ * heal itself (objectui#4363).
2844
3372
  */
2845
3373
  async createView(
2846
3374
  objectName: string,
@@ -2870,16 +3398,57 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
2870
3398
  data: spec?.data || { provider: 'object', object: objectName },
2871
3399
  };
2872
3400
  const result: any = await this.client.meta.saveItem('view', name, fullSpec);
2873
- 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);
2874
3406
  if (result && result.item) return result.item;
2875
3407
  return fullSpec;
2876
3408
  }
2877
3409
 
2878
3410
  /**
2879
3411
  * Apply a partial update to an existing overlay view. Reads the current
2880
- * overlay (or seeds from artifact), merges, and writes back. ADR-0005
2881
- * overlay rows store the *full* view document, so partial updates require
2882
- * a read-merge-write cycle.
3412
+ * document, merges, and writes it back. ADR-0005 overlay rows store the
3413
+ * *full* view document, so partial updates require a read-merge-write cycle.
3414
+ *
3415
+ * **Both halves address the same row (#4139).** A view has two possible
3416
+ * homes and the read must resolve the one the write will target:
3417
+ *
3418
+ * - a pending per-item **draft** (`?state=draft` / `?mode=draft`) — where
3419
+ * ADR-0034 stages every runtime-created view, so a view made from the `+`
3420
+ * tab lives ONLY here until an explicit Publish;
3421
+ * - the **published** overlay (`client.meta.getItem` / `saveItem`).
3422
+ *
3423
+ * The draft is probed FIRST, and a hit is merged and written straight back
3424
+ * as a draft. Two things that ordering buys, both load-bearing:
3425
+ *
3426
+ * 1. A draft-only view is no longer invisible to the read. It used to 404,
3427
+ * and a `catch {}` labelled "treat missing as create-equivalent"
3428
+ * substituted `current = {}` — so a rename merged onto nothing and went
3429
+ * out as a `{label, name, object}` partial the server rejects (422),
3430
+ * while the draft row the UI reads back through `?preview=draft` kept the
3431
+ * old label. The edit was lost with no error surfaced to the user.
3432
+ * 2. A draft is never bypassed. Writing the published row while a draft is
3433
+ * pending would put the edit somewhere the draft shadows — and Publish
3434
+ * would then overwrite it with the pre-edit body, losing the change a
3435
+ * second time, later, where nothing connects it to this call.
3436
+ *
3437
+ * A draft edit stays a draft: `mode: 'draft'` keeps ADR-0037's guarantee
3438
+ * that nothing the preview shows goes live until Publish. Renaming a
3439
+ * *published* view (no draft pending) is unchanged — it writes the
3440
+ * published overlay, as before.
3441
+ *
3442
+ * **Both halves invalidate through {@link invalidateViewKeys}** (objectui#4363,
3443
+ * #4373) — same call, same key set, so the draft half's deliberate
3444
+ * over-invalidation is the seam's uniform rule rather than a per-branch
3445
+ * decision this method has to keep re-making.
3446
+ *
3447
+ * @throws when the view resolves in neither home, or when either read fails
3448
+ * for any other reason (network, permission). Both used to be swallowed
3449
+ * and converted into the bad partial write above; a caller that wants a
3450
+ * view created should call {@link createView}, which is the operation that
3451
+ * actually means "create".
2883
3452
  */
2884
3453
  async updateView(
2885
3454
  objectName: string,
@@ -2887,42 +3456,159 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
2887
3456
  partial: Record<string, any>,
2888
3457
  ): Promise<Record<string, any> | void> {
2889
3458
  await this.connect();
2890
- 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;
2891
3476
  try {
2892
3477
  const r: any = await this.client.meta.getItem('view', viewName);
2893
3478
  current = (r && (r.item || r)) || {};
2894
3479
  // Some endpoints return the bare item; others wrap as {type,name,item}
2895
3480
  if (current?.list) current = current.list;
2896
- } catch {
2897
- // 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;
2898
3496
  }
2899
- const merged = {
2900
- ...current,
2901
- ...partial,
2902
- name: viewName,
2903
- object: current?.object || (current as any)?.data?.object || objectName,
2904
- };
3497
+
3498
+ const merged = mergeViewPatch(current, partial, viewName, objectName);
2905
3499
  const result: any = await this.client.meta.saveItem('view', viewName, merged);
2906
- this.metadataCache.invalidate?.(`views:${objectName}`);
2907
- this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
3500
+ this.invalidateViewKeys(objectName, viewName);
2908
3501
  if (result && result.item) return result.item;
2909
3502
  return merged;
2910
3503
  }
2911
3504
 
2912
3505
  /**
2913
- * Delete an overlay view (reset to artifact default if one exists, or
2914
- * remove entirely if it was a user-created view). Routes to
2915
- * `DELETE /api/v1/meta/view/:name`.
3506
+ * Delete an overlay view — from **every home it has**.
3507
+ *
3508
+ * A view has two possible homes, the same two {@link updateView} addresses:
3509
+ * the pending per-item **draft** (`DELETE /meta/view/:name?state=draft`) and
3510
+ * the **published** overlay (`DELETE /meta/view/:name`). This method used to
3511
+ * issue only the second, and the three cases came out like this (#4479):
3512
+ *
3513
+ * | case | before |
3514
+ * |--------------------|-----------------------------------------------------|
3515
+ * | draft-only view | BUG — the published delete answered `reset:false` / |
3516
+ * | | "nothing to delete", the draft survived, and the tab |
3517
+ * | | was still there after reload |
3518
+ * | published-only | correct — `reset:true`, tab gone |
3519
+ * | published + draft | ACCIDENTALLY correct — the published row went, so |
3520
+ * | | the tab went; the orphan draft stayed behind |
3521
+ *
3522
+ * **Why this is not the mechanical mirror of #4139.** `updateView` probes
3523
+ * the draft first and writes back to whichever home the read resolved, and
3524
+ * that is right for an update in all three cases. Copying it here would be
3525
+ * wrong in the third: `persistRuntimeMetadata` (app-shell) stages EVERY
3526
+ * runtime edit as a draft, so "publish a view, then edit it" routinely
3527
+ * produces a pair — and a draft-first-only delete on a pair discards the
3528
+ * draft and leaves the published row serving the view. That is not Delete
3529
+ * view, it is **Discard draft**, a deliberately different operation that
3530
+ * already exists (`discardRuntimeDraft`, documented as "the published
3531
+ * overlay is untouched"). The clean statement of the asymmetry: for an
3532
+ * update, one home is the right home; for a delete, "remove this view" is
3533
+ * satisfied only when NO home is left serving it.
3534
+ *
3535
+ * So both homes are deleted, **draft first**. The order is load-bearing on
3536
+ * the failure path: a fault between the two calls leaves the PUBLISHED
3537
+ * overlay intact, so the view is still served and the operation is cleanly
3538
+ * retryable. The reverse order would strand a draft-only view — which is
3539
+ * precisely the bug shape above.
3540
+ *
3541
+ * **Two blind calls, no probe.** The framework's `deleteMetaItem` answers a
3542
+ * missing home with a **200** carrying `reset:false` (`"No pending draft
3543
+ * for view/x."` / `"No view 'x' found — nothing to delete."`), never a 404,
3544
+ * so there is nothing for a probe to protect against. `updateView` needs its
3545
+ * probe for a different reason — its read must resolve the row the merge
3546
+ * writes back to — and that reason has no counterpart for a delete.
3547
+ *
3548
+ * **One transport, one error contract.** Both halves go through
3549
+ * {@link MetadataClient} (`reset`), the transport that can express the
3550
+ * `?state=` qualifier and the one `updateView`'s draft half already uses.
3551
+ * The published half used to go through `client.meta.deleteItem`; measured,
3552
+ * that issues the byte-identical request (`DELETE
3553
+ * {baseUrl}/api/v1/meta/view/:name`, no environment scoping is configured on
3554
+ * this adapter), so routing it here costs no addressing change and buys a
3555
+ * single `MetadataError` shape across both calls instead of two.
3556
+ *
3557
+ * @returns `deleted` is true only when no home is left serving the view AND
3558
+ * at least one actually held a row. A view that existed in neither home
3559
+ * still answers `false`, unchanged. The per-home outcomes are additive:
3560
+ * a partial result is observable rather than rounded up to `true`.
3561
+ * @throws when either delete fails, matching {@link updateView}'s
3562
+ * convention of surfacing the fault rather than degrading. A failure of
3563
+ * the PUBLISHED half after the draft was discarded carries the partial
3564
+ * state on the error's `outcome` — "draft gone, overlay left" is exactly
3565
+ * what the old `{ deleted: boolean }` could not express.
3566
+ *
3567
+ * Invalidates through {@link invalidateViewKeys}: the deleted row leaves the
3568
+ * batch override map too, and a ghost entry there is what the object page
3569
+ * would keep applying (objectui#4363). Fired in a `finally`, so it happens
3570
+ * once per call on EVERY outcome including the throw — after a half-failure
3571
+ * the draft row really is gone, and #4363's asymmetry decides it: an
3572
+ * unnecessary invalidation costs one refetch, a missed one costs the cache's
3573
+ * full 5-minute TTL of stale overrides.
2916
3574
  */
2917
3575
  async deleteView(
2918
3576
  objectName: string,
2919
3577
  viewName: string,
2920
- ): Promise<{ deleted: boolean }> {
3578
+ ): Promise<DeleteViewResult> {
2921
3579
  await this.connect();
2922
- const result: any = await this.client.meta.deleteItem('view', viewName);
2923
- this.metadataCache.invalidate?.(`views:${objectName}`);
2924
- this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
2925
- return { deleted: !!(result?.deleted ?? result?.reset ?? true) };
3580
+ const metaClient = this.metadataClient();
3581
+ try {
3582
+ // ── Draft home, first ────────────────────────────────────────────────
3583
+ const draft = readViewDeleteReceipt(
3584
+ await metaClient.reset('view', viewName, { state: 'draft' }),
3585
+ );
3586
+
3587
+ // ── Published home ───────────────────────────────────────────────────
3588
+ let published: ViewHomeDeleteOutcome;
3589
+ try {
3590
+ published = readViewDeleteReceipt(await metaClient.reset('view', viewName));
3591
+ } catch (err) {
3592
+ const outcome: DeleteViewResult = {
3593
+ deleted: false,
3594
+ draft,
3595
+ published: { removed: false },
3596
+ };
3597
+ throw Object.assign(
3598
+ new Error(
3599
+ `deleteView: view "${viewName}" on object "${objectName}" is NOT fully removed` +
3600
+ ` — the draft home was ${draft.removed ? 'discarded' : 'already absent'},` +
3601
+ ' but deleting the published overlay failed. The published row is still' +
3602
+ ' serving the view; retry the delete.',
3603
+ ),
3604
+ { cause: err, outcome },
3605
+ );
3606
+ }
3607
+
3608
+ return { deleted: draft.removed || published.removed, draft, published };
3609
+ } finally {
3610
+ this.invalidateViewKeys(objectName, viewName);
3611
+ }
2926
3612
  }
2927
3613
 
2928
3614
 
@@ -2951,6 +3637,58 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
2951
3637
  }
2952
3638
  }
2953
3639
 
3640
+ /**
3641
+ * Ask the by-name meta app route WHY an app is not in this session's app list
3642
+ * (objectui#4252).
3643
+ *
3644
+ * `GET /api/v1/meta/apps` is filtered per session server-side
3645
+ * (`filterAppForUser`), so an app withheld by `requiredPermissions` and an app
3646
+ * that does not exist are byte-identical there: both are simply absent. A
3647
+ * console reading only that list has one fact and two conditions, and it
3648
+ * renders its copy for the wrong one — "it may still be publishing" over a
3649
+ * permanent authorization decision, which cost a downstream acceptance round
3650
+ * two test batches spent chasing a platform defect that was a missing
3651
+ * permission-set binding.
3652
+ *
3653
+ * The maintainer ruling (2026-08-12) put the answer on the BY-NAME route
3654
+ * rather than in the list, so the enumeration surface is not widened past what
3655
+ * a by-name probe already implies (objectstack#8013 / PR #8135): an app that
3656
+ * exists and whose `requiredPermissions` the session lacks answers `403` with
3657
+ * `PERMISSION_DENIED` in the declared envelope, and absence — a nonexistent
3658
+ * name, an unpublished app, an app gated by an absent optional service —
3659
+ * keeps answering `404 RESOURCE_NOT_FOUND`.
3660
+ *
3661
+ * ## Why this is a separate method and not a flavour of {@link getApp}
3662
+ *
3663
+ * - `getApp` degrades EVERY failure to `null`, which is exactly the
3664
+ * conflation this exists to undo; changing it would silently re-point its
3665
+ * own callers' fallback-to-static-config path.
3666
+ * - `getApp` memoises in `metadataCache`. A verdict about the CALLER must not
3667
+ * be cached beside a document about the APP — one grant, and a cached
3668
+ * denial outlives the session it described.
3669
+ *
3670
+ * Nothing here throws: a probe that cannot reach an answer returns `unknown`
3671
+ * and the caller keeps whatever it was already showing. Only the measured
3672
+ * `code` produces `denied` — never a status, never a message (objectui#4408).
3673
+ *
3674
+ * @param appName - the app name as it appears in the URL segment
3675
+ */
3676
+ async probeAppAccess(appName: string): Promise<AppAccessVerdict> {
3677
+ if (!appName) return 'unknown';
3678
+ try {
3679
+ // Singular `app`, the address objectstack#8013 pinned its cases against,
3680
+ // and the same one `MetadataProvider` reads items by. No `connect()`
3681
+ // first: this route is only ever asked after the app LIST has already
3682
+ // loaded through this same client, and the client's route resolution
3683
+ // falls back to the conventional `/api/v1/meta` regardless — so a
3684
+ // discovery round trip here could only add a failure mode.
3685
+ await this.client.meta.getItem('app', appName);
3686
+ return 'granted';
3687
+ } catch (err) {
3688
+ return isAppPermissionDeniedError(err) ? 'denied' : 'unknown';
3689
+ }
3690
+ }
3691
+
2954
3692
  /**
2955
3693
  * Get a page definition from ObjectStack.
2956
3694
  * Uses the metadata API to fetch page layouts.
@@ -3179,36 +3917,70 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
3179
3917
  * silently returning wrong numbers.
3180
3918
  *
3181
3919
  * @param dataset - An inline dataset definition (draft) OR a saved dataset name.
3182
- * @param selection - 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`.
3183
3931
  */
3184
3932
  async queryDataset(
3185
3933
  dataset: Record<string, unknown> | string,
3186
- selection: {
3187
- dimensions?: string[];
3188
- measures: string[];
3189
- runtimeFilter?: Record<string, unknown>;
3190
- timeDimensions?: unknown[];
3191
- compareTo?: { kind: 'previousPeriod' | 'previousYear'; dimension: string };
3192
- order?: Record<string, 'asc' | 'desc'>;
3193
- limit?: number;
3194
- offset?: number;
3195
- timezone?: string;
3196
- /** Marginal-aggregate groupings (e.g. `[rows, [colDim], []]`) — server
3197
- * computes each subtotal with the measure's TRUE aggregate (never client
3198
- * re-derived). `[]` is the grand total. */
3199
- totals?: { groupings: string[][] };
3200
- },
3934
+ selection: DatasetSelection,
3201
3935
  ): Promise<{
3202
3936
  rows: Array<Record<string, unknown>>;
3203
- /** Column metadata: a display `label` (dimensions and measures), and a
3204
- * measure's numeral `format` + declared `currency` for value formatting. */
3205
- fields: Array<{ name: string; type: string; label?: string; format?: string; currency?: string }>;
3937
+ /**
3938
+ * Column metadata — the spec's `AnalyticsResult.fields[]` element BY
3939
+ * REFERENCE, never a local restatement of it (objectui#3752). Read
3940
+ * `@objectstack/spec` for what a column carries; this comment deliberately
3941
+ * does not re-list the keys, because the enumeration it replaced was the
3942
+ * bug: it named five (`name`/`type`/`label`/`format`/`currency`) and stopped
3943
+ * at the contract of the day it was written, so it never grew
3944
+ * `percentScale` — the server's answer to whether a percentage column is a
3945
+ * 0–1 fraction or already percentage points. The spec says a renderer that
3946
+ * receives it "must scale by it instead of guessing from the value"
3947
+ * (objectui#3136), so a declaration that hides the key steers a typed
3948
+ * consumer into exactly the guess-by-magnitude the issue banned. Pinned in
3949
+ * `queryDataset.test.ts`.
3950
+ *
3951
+ * Only this element is spec-owned: the envelope around it (`object` /
3952
+ * `dimensionFields` / `drillRawRows`) is ADR-0021 D2 drill metadata the REST
3953
+ * route adds on top of `AnalyticsResult`, and this method never returns the
3954
+ * result's `sql`, so the whole envelope is NOT an `AnalyticsResult`.
3955
+ */
3956
+ fields: Array<AnalyticsResult['fields'][number]>;
3206
3957
  /** ADR-0021 D2 drill-through: the dataset's base object (records to drill into). */
3207
3958
  object?: string;
3208
3959
  /** Drillable dimension NAME → underlying object FIELD name. */
3209
3960
  dimensionFields?: Record<string, string>;
3210
3961
  /** Raw grouped values per row (aligned to `rows` by index) for drill filters. */
3211
3962
  drillRawRows?: Array<Record<string, unknown>>;
3963
+ /**
3964
+ * Half-open date-range drill scope per row (framework#1752), aligned to
3965
+ * `rows` by index: dimension NAME → the field and `[gte, lt)` bounds of that
3966
+ * row's time bucket. The RANGE companion to `drillRawRows`, which handles
3967
+ * equality dims only — a `dateGranularity` dimension groups a SPAN of
3968
+ * records into one bucket, so the server excludes date dims from
3969
+ * `dimensionFields`/`drillRawRows` and sends this sidecar instead.
3970
+ *
3971
+ * The entry type is `@object-ui/core`'s `DatasetDrillRange` BY REFERENCE,
3972
+ * not a local restatement of it (objectui#3613/#3752 discipline): the same
3973
+ * declaration is what `buildDatasetDrillFilter` — the single consumer that
3974
+ * turns these bounds into an ObjectQL `{ $gte, $lt }` — accepts, and what
3975
+ * `DatasetWidget` / `DatasetReportRenderer` type their state with. Nothing
3976
+ * in `@objectstack/spec` owns this shape yet (the server's own
3977
+ * `AnalyticsResultWithDrill` is local to `service-analytics`), so the shared
3978
+ * in-repo interface is the one contract available; restating it here would
3979
+ * make a third dialect of it. Like `drillRawRows`, only the ARRAY is
3980
+ * validated below — the bounds are unvalidated payload, which is exactly why
3981
+ * `DatasetDrillRange` declares them `unknown`.
3982
+ */
3983
+ drillRanges?: Array<Record<string, DatasetDrillRange>>;
3212
3984
  /** Server-computed marginal aggregates, one entry per requested grouping. */
3213
3985
  totals?: Array<{ dimensions: string[]; rows: Array<Record<string, unknown>> }>;
3214
3986
  }> {
@@ -3275,8 +4047,16 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
3275
4047
  ? ((data as any).dimensionFields as Record<string, string>)
3276
4048
  : undefined;
3277
4049
  const drillRawRows = Array.isArray((data as any)?.drillRawRows) ? (data as any).drillRawRows : undefined;
4050
+ // framework#1752 — the date-range sidecar. Dropping it here (objectui#3813)
4051
+ // made date-bucket drill-through impossible through the only real adapter:
4052
+ // a widget grouped ONLY by a date dimension gets no `dimensionFields` (the
4053
+ // server excludes date dims from the equality drill), so `drillRanges` is
4054
+ // the ONLY thing that can make `canDrill` true — with the key hand-picked
4055
+ // away, the whole drill entry point disappeared, and a mixed grouping
4056
+ // drilled to a superset (every bucket, not the clicked one).
4057
+ const drillRanges = Array.isArray((data as any)?.drillRanges) ? (data as any).drillRanges : undefined;
3278
4058
  const totals = Array.isArray((data as any)?.totals) ? (data as any).totals : undefined;
3279
- return { rows, fields, object, dimensionFields, drillRawRows, totals };
4059
+ return { rows, fields, object, dimensionFields, drillRawRows, drillRanges, totals };
3280
4060
  }
3281
4061
 
3282
4062
  /** Client-side aggregation fallback */
@@ -3610,9 +4390,10 @@ export {
3610
4390
  export type { MetadataCacheStats } from './cache/MetadataCache';
3611
4391
 
3612
4392
  // v3.0.0 Deep Integration modules
3613
- export { CloudOperations } from './cloud';
3614
- export type { CloudDeploymentConfig, CloudHostingConfig, CloudMarketplaceEntry } from './cloud';
3615
-
4393
+ // (The cloud deployment/hosting/marketplace surface that used to be re-exported
4394
+ // here was retired by objectui#4152 — it called into a client namespace that
4395
+ // does not exist and fabricated success. `cloud-surface-retired-4152.pin.test.ts`
4396
+ // fails if it returns; that file names the retired symbols, this one must not.)
3616
4397
  export { validatePluginContract, generateContractManifest } from './contracts';
3617
4398
 
3618
4399
  // User-scoped persistence adapter (favorites / recent items / …)
@@ -3630,8 +4411,11 @@ export type { IntegrationConfig, IntegrationTrigger, IntegrationProvider, SlackI
3630
4411
  // Used by plugin-designer to back the Setup-app Object Manager and Field
3631
4412
  // Designer surfaces; kept separate from ObjectStackAdapter so callers
3632
4413
  // can use it without the full data-source surface.
3633
- export { MetadataClient } from './metadata-client';
4414
+ export { MetadataClient, readSaveAdvisories } from './metadata-client';
3634
4415
  export type {
4416
+ RuntimeAuthoringIssue,
4417
+ MetadataSaveAdvisoryEvent,
4418
+ MetadataSaveAdvisoryListener,
3635
4419
  MetadataClientConfig,
3636
4420
  MetadataListOptions,
3637
4421
  MetadataDraftHeader,