@object-ui/data-objectstack 17.3.0 → 17.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +888 -0
  2. package/README.md +3 -11
  3. package/dist/index.cjs +1511 -1115
  4. package/dist/index.d.cts +1222 -824
  5. package/dist/index.d.ts +1222 -824
  6. package/dist/index.js +1503 -1111
  7. package/package.json +5 -6
  8. package/src/aggregate-capability.test.ts +0 -177
  9. package/src/bulkDelete.test.ts +0 -75
  10. package/src/bulkUpdate.test.ts +0 -93
  11. package/src/cache/MetadataCache.test.ts +0 -477
  12. package/src/cache/MetadataCache.ts +0 -289
  13. package/src/cloud.ts +0 -109
  14. package/src/connection.test.ts +0 -188
  15. package/src/contracts.ts +0 -115
  16. package/src/errors.test.ts +0 -426
  17. package/src/errors.ts +0 -293
  18. package/src/exportDownload.test.ts +0 -141
  19. package/src/filter-entry-translation.test.ts +0 -360
  20. package/src/filter-operator-ast-parity.test.ts +0 -71
  21. package/src/getObjectSchema.test.ts +0 -145
  22. package/src/importJob.test.ts +0 -71
  23. package/src/index.ts +0 -3658
  24. package/src/integration.ts +0 -200
  25. package/src/listViews.test.ts +0 -175
  26. package/src/metadata-client.test.ts +0 -303
  27. package/src/metadata-client.ts +0 -849
  28. package/src/occ.test.ts +0 -101
  29. package/src/onMutation.test.ts +0 -562
  30. package/src/onWriteWarning.test.ts +0 -273
  31. package/src/orderby-serialization.test.ts +0 -115
  32. package/src/queryDataset.test.ts +0 -132
  33. package/src/searchAll.test.ts +0 -120
  34. package/src/security.ts +0 -243
  35. package/src/spec-symbol-batch6.test.ts +0 -306
  36. package/src/studio.ts +0 -152
  37. package/src/userState.test.ts +0 -315
  38. package/src/userState.ts +0 -238
  39. package/src/v3-compat.test.ts +0 -240
  40. package/src/validation-error.test.ts +0 -94
package/dist/index.cjs CHANGED
@@ -22,803 +22,910 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
22
22
  // src/index.ts
23
23
  var index_exports = {};
24
24
  __export(index_exports, {
25
+ API_ACCESS_DENIED_CODES: () => API_ACCESS_DENIED_CODES,
26
+ APP_PERMISSION_DENIED_CODE: () => APP_PERMISSION_DENIED_CODE,
27
+ AnalyticsDatasetNotFoundError: () => AnalyticsDatasetNotFoundError,
25
28
  AnalyticsNotInstalledError: () => AnalyticsNotInstalledError,
26
29
  AnalyticsQueryRejectedError: () => AnalyticsQueryRejectedError,
30
+ AnalyticsUnauthenticatedError: () => AnalyticsUnauthenticatedError,
27
31
  AuthenticationError: () => AuthenticationError,
28
32
  BulkOperationError: () => BulkOperationError,
29
- CloudOperations: () => CloudOperations,
30
33
  ConcurrentUpdateError: () => ConcurrentUpdateError,
31
34
  ConnectionError: () => ConnectionError,
32
35
  DataApiValidationError: () => DataApiValidationError,
33
36
  FILTER_OPERATOR_ALIASES: () => FILTER_OPERATOR_ALIASES,
34
- IntegrationManager: () => IntegrationManager,
35
37
  MalformedFilterError: () => MalformedFilterError,
36
38
  MetadataClient: () => MetadataClient,
37
39
  MetadataNotFoundError: () => MetadataNotFoundError,
38
40
  ObjectStackAdapter: () => ObjectStackAdapter,
39
41
  ObjectStackError: () => ObjectStackError,
40
- SecurityManager: () => SecurityManager,
41
- calculateAutoLayout: () => calculateAutoLayout,
42
+ VIEW_OVERLAY_OWNED_KEYS: () => VIEW_OVERLAY_OWNED_KEYS,
42
43
  classifyAnalyticsFailure: () => classifyAnalyticsFailure,
43
44
  clearSharedDiscoveryCache: () => clearSharedDiscoveryCache,
44
- createDefaultCanvasConfig: () => createDefaultCanvasConfig,
45
45
  createErrorFromResponse: () => createErrorFromResponse,
46
46
  createObjectStackAdapter: () => createObjectStackAdapter,
47
47
  createObjectStackUserStateAdapter: () => createObjectStackUserStateAdapter,
48
- generateContractManifest: () => generateContractManifest,
48
+ createQuietHttpLogger: () => createQuietHttpLogger,
49
+ formatHttpFailureMessage: () => formatHttpFailureMessage,
49
50
  getSharedDiscovery: () => getSharedDiscovery,
50
51
  is404Error: () => is404Error,
51
52
  isAnalyticsNotInstalledError: () => isAnalyticsNotInstalledError,
53
+ isApiAccessDeniedError: () => isApiAccessDeniedError,
54
+ isAppPermissionDeniedError: () => isAppPermissionDeniedError,
52
55
  isConcurrentUpdateError: () => isConcurrentUpdateError,
53
56
  isErrorType: () => isErrorType,
54
57
  isMalformedFilterError: () => isMalformedFilterError,
55
58
  isObjectStackError: () => isObjectStackError,
59
+ narrowPersonalizationOverlay: () => narrowPersonalizationOverlay,
56
60
  normaliseClientError: () => normaliseClientError,
61
+ readSaveAdvisories: () => readSaveAdvisories,
57
62
  readTransactionalBatchCapability: () => readTransactionalBatchCapability,
58
63
  serializeOrderBy: () => serializeOrderBy,
59
- snapToGrid: () => snapToGrid,
60
- validatePluginContract: () => validatePluginContract
64
+ viewItemObjectName: () => viewItemObjectName
61
65
  });
62
66
  module.exports = __toCommonJS(index_exports);
63
67
  var import_client = require("@objectstack/client");
64
68
 
65
- // ../types/src/index.ts
66
- var import_spec = require("@objectstack/spec");
67
-
68
- // ../types/src/error-code.ts
69
- function errorCodeIs(err, code) {
70
- const raw = err?.code;
71
- if (typeof raw !== "string" || raw.length === 0) return false;
72
- return raw.toUpperCase() === code.toUpperCase();
69
+ // src/metadata-client.ts
70
+ function readSaveAdvisories(body) {
71
+ if (!body || typeof body !== "object") return [];
72
+ const raw = body.advisories;
73
+ if (!Array.isArray(raw)) return [];
74
+ return raw.filter((f) => {
75
+ if (!f || typeof f !== "object") return false;
76
+ const c = f;
77
+ return typeof c.rule === "string" && typeof c.path === "string" && typeof c.where === "string" && typeof c.message === "string" && typeof c.hint === "string" && typeof c.severity === "string";
78
+ });
73
79
  }
74
- function errorCodeIsAnyOf(err, codes) {
75
- return codes.some((c) => errorCodeIs(err, c));
80
+ var META_PREFIX = "/meta";
81
+ var API_PREFIX = "/api/v1";
82
+ function buildBase(config) {
83
+ const trimmed = config.baseUrl.replace(/\/+$/, "");
84
+ const scoped = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
85
+ if (/\/api\/v\d+$/i.test(trimmed)) {
86
+ const stripped = trimmed.replace(/\/api\/v\d+$/i, "");
87
+ const scopedNoApi = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
88
+ return `${stripped}${scopedNoApi}`;
89
+ }
90
+ return `${trimmed}${scoped}`;
76
91
  }
77
-
78
- // ../core/src/utils/filter-converter.ts
79
- var import_ui = require("@objectstack/spec/ui");
80
- var FilterOperatorError = class extends Error {
81
- constructor(message) {
82
- super(message);
83
- __publicField(this, "code", "INVALID_FILTER");
84
- __publicField(this, "httpStatus", 400);
85
- this.name = "FilterOperatorError";
92
+ async function parseError(res) {
93
+ let body;
94
+ try {
95
+ body = await res.json();
96
+ } catch {
97
+ body = await res.text().catch(() => void 0);
86
98
  }
87
- };
88
- function convertOperatorToAST(operator) {
89
- const operatorMap = {
90
- "$eq": "=",
91
- "$ne": "!=",
92
- "$gt": ">",
93
- "$gte": ">=",
94
- "$lt": "<",
95
- "$lte": "<=",
96
- "$in": "in",
97
- "$nin": "nin",
98
- "$notin": "nin",
99
- "$between": "between",
100
- "$contains": "contains",
101
- "$notContains": "notcontains",
102
- "$notcontains": "notcontains",
103
- "$startsWith": "startswith",
104
- "$startswith": "startswith",
105
- "$endsWith": "endswith",
106
- "$endswith": "endswith"
107
- };
108
- return operatorMap[operator] || null;
99
+ const bodyObj = body && typeof body === "object" ? body : void 0;
100
+ const errNode = bodyObj?.error;
101
+ const message = (errNode && typeof errNode === "object" ? errNode.message : errNode) ?? bodyObj?.message ?? `Metadata request failed: ${res.status} ${res.statusText}`;
102
+ const err = new Error(String(message));
103
+ err.status = res.status;
104
+ err.body = body;
105
+ const details = errNode && typeof errNode === "object" ? errNode.details : void 0;
106
+ const issues = details?.issues ?? (errNode && typeof errNode === "object" ? errNode.issues : void 0) ?? bodyObj?.issues;
107
+ if (Array.isArray(issues)) err.issues = issues;
108
+ const code = details?.code ?? (errNode && typeof errNode === "object" ? errNode.code : void 0) ?? bodyObj?.code;
109
+ if (code != null) err.code = String(code);
110
+ return err;
109
111
  }
110
- function convertFiltersToAST(filter) {
111
- const conditions = [];
112
- for (const [field, value] of Object.entries(filter)) {
113
- if (value === null || value === void 0) continue;
114
- if (typeof value === "object" && !Array.isArray(value)) {
115
- for (const [operator, operatorValue] of Object.entries(value)) {
116
- if (operator === "$regex") {
117
- throw new FilterOperatorError(
118
- `[ObjectUI] The '$regex' filter operator is not supported. It used to be converted to 'contains', which matches a literal substring rather than a pattern \u2014 a different result, not a degraded one. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. Use $contains, $startsWith or $endsWith.`
119
- );
120
- }
121
- if (operator === "$null") {
122
- conditions.push([field, operatorValue ? "is_null" : "is_not_null", true]);
123
- continue;
124
- }
125
- if (operator === "$exists") {
126
- conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
127
- continue;
128
- }
129
- const astOperator = convertOperatorToAST(operator);
130
- if (astOperator) {
131
- conditions.push([field, astOperator, operatorValue]);
132
- } else {
133
- throw new FilterOperatorError(
134
- `[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, $notContains, $startsWith, $endsWith, $null, $exists. If you need exact object matching, use the value directly without an operator.`
135
- );
136
- }
137
- }
138
- } else {
139
- conditions.push([field, "=", value]);
140
- }
112
+ function isMetaItemEnvelope(value) {
113
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
114
+ const env = value;
115
+ return typeof env.type === "string" && typeof env.name === "string" && "item" in env;
116
+ }
117
+ var MetadataClient = class _MetadataClient {
118
+ constructor(config) {
119
+ __publicField(this, "base");
120
+ __publicField(this, "fetchImpl");
121
+ __publicField(this, "headers");
122
+ /** ADR-0037: when true, reads render the draft-overlaid world. */
123
+ __publicField(this, "previewDrafts");
124
+ /** #4133 — sink for post-save advisory findings; see the config field. */
125
+ __publicField(this, "onSaveAdvisory");
126
+ this.base = buildBase(config);
127
+ this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
128
+ this.headers = { Accept: "application/json", ...config.headers ?? {} };
129
+ this.previewDrafts = config.previewDrafts === true;
130
+ this.onSaveAdvisory = config.onSaveAdvisory;
141
131
  }
142
- if (conditions.length === 0) {
143
- return filter;
132
+ /** Update the client's environment scope at runtime. */
133
+ withEnvironment(environmentId) {
134
+ return new _MetadataClient({
135
+ baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
136
+ environmentId,
137
+ fetch: this.fetchImpl,
138
+ headers: this.headers,
139
+ previewDrafts: this.previewDrafts,
140
+ // #4133: the advisory sink must survive the clone. `useMetadataClient`
141
+ // routes EVERY environment-scoped console client through here, so
142
+ // dropping it would silently disable the channel for exactly the
143
+ // multi-environment tenants the gate is loudest for.
144
+ ...this.onSaveAdvisory ? { onSaveAdvisory: this.onSaveAdvisory } : {}
145
+ });
144
146
  }
145
- if (conditions.length === 1) {
146
- return conditions[0];
147
+ /**
148
+ * Derive a client whose reads render the draft-overlaid world (or not).
149
+ * Same base/fetch/headers; used by the app-shell to switch the whole
150
+ * renderer tree into ADR-0037 preview mode off one URL flag.
151
+ */
152
+ withPreviewDrafts(previewDrafts) {
153
+ if (previewDrafts === this.previewDrafts) return this;
154
+ const envMatch = this.base.match(/\/api\/v\d+\/environments\/([^/]+)\/meta$/);
155
+ return new _MetadataClient({
156
+ baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
157
+ ...envMatch ? { environmentId: decodeURIComponent(envMatch[1]) } : {},
158
+ fetch: this.fetchImpl,
159
+ headers: this.headers,
160
+ previewDrafts,
161
+ // #4133: carried for the same reason as in `withEnvironment` — preview
162
+ // mode swaps the whole renderer tree's client, and writes are unaffected
163
+ // by ADR-0037, so the save door behind this clone still advises.
164
+ ...this.onSaveAdvisory ? { onSaveAdvisory: this.onSaveAdvisory } : {}
165
+ });
147
166
  }
148
- return ["and", ...conditions];
149
- }
150
-
151
- // ../core/src/adapters/batchTransaction.ts
152
- function idOf(rec) {
153
- if (rec == null) return void 0;
154
- return rec.id ?? rec._id ?? rec.recordId;
155
- }
156
- function resolveRefs(data, results) {
157
- const out = {};
158
- for (const [key, value] of Object.entries(data)) {
159
- if (value && typeof value === "object" && !Array.isArray(value) && typeof value.$ref === "number") {
160
- const refIndex = value.$ref;
161
- const id = idOf(results[refIndex]);
162
- if (id === void 0) {
163
- throw new Error(
164
- `batchTransaction: field "${key}" references operation ${refIndex}, which has not produced an id (forward or invalid $ref)`
165
- );
166
- }
167
- out[key] = id;
168
- } else {
169
- out[key] = value;
167
+ /** List all registered metadata types (returns the registry rows). */
168
+ async listTypes() {
169
+ const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
170
+ if (!res.ok) throw await parseError(res);
171
+ const data = await res.json();
172
+ if (Array.isArray(data)) return data;
173
+ if (data && Array.isArray(data.entries)) return data.entries;
174
+ if (data && Array.isArray(data.items)) return data.items;
175
+ if (data && Array.isArray(data.types)) {
176
+ return data.types.map((t) => ({ type: t }));
170
177
  }
178
+ return [];
171
179
  }
172
- return out;
173
- }
174
- async function emulateBatchTransaction(dataSource, operations) {
175
- const results = [];
176
- const created = [];
177
- try {
178
- for (const op of operations) {
179
- const action = op.action ?? "create";
180
- if (action === "create") {
181
- const record = await dataSource.create(
182
- op.object,
183
- resolveRefs(op.data ?? {}, results)
184
- );
185
- results.push(record);
186
- const id = idOf(record);
187
- if (id) created.push({ object: op.object, id });
188
- } else if (action === "update") {
189
- if (op.id == null) {
190
- throw new Error(`batchTransaction: update on "${op.object}" requires an id`);
191
- }
192
- results.push(
193
- await dataSource.update(
194
- op.object,
195
- op.id,
196
- resolveRefs(op.data ?? {}, results)
197
- )
198
- );
199
- } else {
200
- if (op.id == null) {
201
- throw new Error(`batchTransaction: delete on "${op.object}" requires an id`);
202
- }
203
- results.push(await dataSource.delete(op.object, op.id));
204
- }
205
- }
206
- return { results };
207
- } catch (err) {
208
- if (created.length > 0) {
209
- await Promise.allSettled(
210
- [...created].reverse().map((c) => dataSource.delete(c.object, c.id))
211
- );
180
+ /** List items of a metadata type (e.g. `object`, `field`, `view`). */
181
+ async list(type, options = {}) {
182
+ const params = [];
183
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
184
+ if (this.previewDrafts) params.push("preview=draft");
185
+ const qs = params.length ? `?${params.join("&")}` : "";
186
+ const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
187
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
188
+ if (!res.ok) throw await parseError(res);
189
+ const data = await res.json();
190
+ if (Array.isArray(data)) return data;
191
+ if (data && Array.isArray(data.items)) {
192
+ return data.items;
212
193
  }
213
- throw err;
194
+ return [];
195
+ }
196
+ /**
197
+ * List pending DRAFT items (ADR-0033) — what an AI authored but nobody
198
+ * published yet. `list()` only sees published/active metadata, so a
199
+ * just-built app package looks empty there; this surfaces the drafts so the
200
+ * console can show a "pending changes" view and draft-aware package contents.
201
+ * Optionally narrow by `packageId` and/or `type`. Returns light headers
202
+ * (no body) carrying `packageId` for grouping.
203
+ */
204
+ async listDrafts(options = {}) {
205
+ const params = [];
206
+ if (options.packageId) params.push(`packageId=${encodeURIComponent(options.packageId)}`);
207
+ if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
208
+ const qs = params.length ? `?${params.join("&")}` : "";
209
+ const url = `${this.base}/_drafts${qs}`;
210
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
211
+ if (!res.ok) throw await parseError(res);
212
+ const data = await res.json();
213
+ if (Array.isArray(data)) return data;
214
+ return data?.drafts ?? data?.data?.drafts ?? [];
215
+ }
216
+ /**
217
+ * Fetch a single metadata item and return the response body EXACTLY as the
218
+ * server sent it — envelope and all.
219
+ *
220
+ * The one transport both {@link get} (which unwraps) and {@link getDraft}
221
+ * (which does not) sit on, so the two can differ in what they hand back
222
+ * without differing in how they ask. Private on purpose: the envelope is a
223
+ * wire detail, and the two published contracts above are the supported ways
224
+ * to read an item.
225
+ */
226
+ async readItemResponse(type, name, options = {}) {
227
+ const params = [];
228
+ if (options.state === "draft") params.push("state=draft");
229
+ else if (this.previewDrafts) params.push("preview=draft");
230
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
231
+ const qs = params.length ? `?${params.join("&")}` : "";
232
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
233
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
234
+ if (res.status === 404) return null;
235
+ if (!res.ok) throw await parseError(res);
236
+ return await res.json();
214
237
  }
215
- }
216
-
217
- // ../core/src/utils/reference-keys.ts
218
- function normalizeFieldReferenceKeys(fieldDef) {
219
- if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
220
- const f = fieldDef;
221
- const target = f.reference_to ?? f.reference ?? f.referenceTo;
222
- if (target == null || target === "") return fieldDef;
223
- if (f.reference_to === void 0) f.reference_to = target;
224
- if (f.reference === void 0) f.reference = target;
225
- return fieldDef;
226
- }
227
- function normalizeSchemaReferenceKeys(schema) {
228
- const fields = schema && typeof schema === "object" ? schema.fields : null;
229
- if (!fields || typeof fields !== "object") return schema;
230
- const defs = Array.isArray(fields) ? fields : Object.values(fields);
231
- for (const def of defs) normalizeFieldReferenceKeys(def);
232
- return schema;
233
- }
234
-
235
- // src/cache/MetadataCache.ts
236
- var MetadataCache = class {
237
238
  /**
238
- * Create a new MetadataCache instance
239
- *
240
- * @param options - Configuration options
241
- * @param options.maxSize - Maximum number of entries (default: 100)
242
- * @param options.ttl - Time to live in milliseconds (default: 5 minutes)
239
+ * Get a single metadata item. Returns the unwrapped item CONTENT — the
240
+ * metadata document itself, so `obj.fields` / `obj.label` are reachable
241
+ * directly.
242
+ *
243
+ * The framework answers `GET /meta/:type/:name` with the spec-declared
244
+ * envelope `{ type, name, item, …protection fields }`
245
+ * (`GetMetaItemResponseSchema`; objectstack#5563 collapsed this read to that
246
+ * ONE shape), so the envelope is unwrapped here — at the client boundary,
247
+ * once — rather than by each caller.
248
+ *
249
+ * **This method used to return the envelope while promising the body**
250
+ * (objectui#4271). Nothing detected the disagreement, because the test
251
+ * doubles across the repo were written against this docblock: every consumer
252
+ * reading `obj.fields` got `undefined` in production and a field list in
253
+ * unit tests. The visible cost was the entire field half of the permission
254
+ * matrix reporting "No fields registered for this object." for every object,
255
+ * plus dead RLS CEL autocomplete, an inert report drill-down and a designer
256
+ * that saved the envelope back over the object body.
257
+ *
258
+ * A response that is NOT the envelope (an older server answering the bare
259
+ * document, or a body of its own shape) passes through untouched, and `null`
260
+ * still comes back on 404 to keep the call site ergonomic.
243
261
  */
244
- constructor(options = {}) {
245
- __publicField(this, "cache");
246
- __publicField(this, "inflight");
247
- __publicField(this, "maxSize");
248
- __publicField(this, "ttl");
249
- __publicField(this, "stats");
250
- this.cache = /* @__PURE__ */ new Map();
251
- this.inflight = /* @__PURE__ */ new Map();
252
- this.maxSize = options.maxSize || 100;
253
- this.ttl = options.ttl || 5 * 60 * 1e3;
254
- this.stats = {
255
- hits: 0,
256
- misses: 0,
257
- evictions: 0,
258
- coalesced: 0
259
- };
262
+ async get(type, name, options = {}) {
263
+ const resp = await this.readItemResponse(type, name, options);
264
+ if (isMetaItemEnvelope(resp)) return resp.item ?? null;
265
+ return resp;
260
266
  }
261
267
  /**
262
- * Get a value from cache or fetch it using the provided fetcher function
263
- *
264
- * @param key - Cache key
265
- * @param fetcher - Async function to fetch data if not in cache
266
- * @returns Promise resolving to the cached or fetched data
268
+ * Read the pending draft body for an item (`?state=draft`). Returns
269
+ * `null` when there is no draft pending. Draft reads do NOT fall
270
+ * back to the published overlay or the artifact registry — a `null`
271
+ * unambiguously means "nothing to publish".
272
+ *
273
+ * Note: this method hands back the `{ type, name, item }` envelope the
274
+ * framework sends, NOT the body — callers read `.item`. That asymmetry with
275
+ * {@link get} is deliberate and long-standing (the draft envelope's identity
276
+ * and protection carriers are part of what a draft reader inspects), so it
277
+ * is preserved by reading the transport directly instead of going through
278
+ * `get()`'s unwrap. `unwrapDraftBody` (app-shell) and `unwrapViewDraft`
279
+ * (this package) are the shared helpers for taking the body out.
267
280
  */
268
- async get(key, fetcher) {
269
- const now = Date.now();
270
- const cached = this.cache.get(key);
271
- if (cached) {
272
- const age = now - cached.timestamp;
273
- if (age < this.ttl) {
274
- cached.accessCount++;
275
- cached.lastAccessed = now;
276
- this.stats.hits++;
277
- this.cache.delete(key);
278
- this.cache.set(key, cached);
279
- return cached.data;
280
- } else {
281
- this.cache.delete(key);
282
- }
283
- }
284
- const existing = this.inflight.get(key);
285
- if (existing) {
286
- this.stats.coalesced++;
287
- return existing;
281
+ async getDraft(type, name, options = {}) {
282
+ return this.readItemResponse(type, name, {
283
+ state: "draft",
284
+ ...options.packageId ? { packageId: options.packageId } : {}
285
+ });
286
+ }
287
+ /**
288
+ * Save (PUT) a metadata item. The framework accepts both the bare
289
+ * item payload and the `{ item: ... }` / `{ metadata: ... }`
290
+ * envelopes; we send bare for consistency. Pass `mode: 'draft'` to
291
+ * stage the change without publishing (Studio's "Save" button).
292
+ */
293
+ async save(type, name, item, options = {}) {
294
+ if (!name || !String(name).trim()) {
295
+ throw new Error(
296
+ `MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
297
+ );
288
298
  }
289
- this.stats.misses++;
290
- const promise = (async () => {
299
+ const params = [];
300
+ if (options.force) params.push("force=true");
301
+ if (options.mode === "draft") params.push("mode=draft");
302
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
303
+ const qs = params.length ? `?${params.join("&")}` : "";
304
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
305
+ const headers = {
306
+ ...this.headers,
307
+ "Content-Type": "application/json"
308
+ };
309
+ if (options.ifMatch) headers["If-Match"] = options.ifMatch;
310
+ const res = await this.fetchImpl(url, {
311
+ method: "PUT",
312
+ headers,
313
+ body: JSON.stringify(item)
314
+ });
315
+ if (!res.ok) throw await parseError(res);
316
+ const body = await res.json();
317
+ if (this.onSaveAdvisory) {
291
318
  try {
292
- const data = await fetcher();
293
- this.set(key, data);
294
- return data;
295
- } finally {
296
- this.inflight.delete(key);
319
+ const advisories = readSaveAdvisories(body);
320
+ if (advisories.length > 0) {
321
+ this.onSaveAdvisory({
322
+ type,
323
+ name,
324
+ mode: options.mode === "draft" ? "draft" : "publish",
325
+ advisories
326
+ });
327
+ }
328
+ } catch {
297
329
  }
298
- })();
299
- this.inflight.set(key, promise);
300
- return promise;
330
+ }
331
+ return body;
301
332
  }
302
333
  /**
303
- * Prime the cache with a pre-fetched value. Useful when a bulk endpoint
304
- * (e.g. list of all object schemas) returns data that would otherwise
305
- * be fetched again per item.
334
+ * Publish a single pending draft BY REFERENCE — promotes the draft row for
335
+ * `(type, name)` into the active overlay and drops the draft. Works for ANY
336
+ * draft, including ones with no `packageId` binding (which the package-scoped
337
+ * `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
338
+ * exact set returned by {@link listDrafts} without needing a package.
339
+ *
340
+ * Returns the server result. For `seed` drafts the protocol also materializes
341
+ * the rows and reports under `seedApplied` — a data problem never fails the
342
+ * publish, so callers should check `seedApplied?.success` and warn the user
343
+ * rather than assume the data went live.
306
344
  */
307
- prime(key, data) {
308
- this.set(key, data);
345
+ async publishDraft(type, name) {
346
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
347
+ const res = await this.fetchImpl(url, {
348
+ method: "POST",
349
+ headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
350
+ body: "{}"
351
+ });
352
+ if (!res.ok) throw await parseError(res);
353
+ const body = await res.json().catch(() => ({}));
354
+ const inner = body?.data && typeof body.data === "object" ? body.data : body;
355
+ return inner;
309
356
  }
310
357
  /**
311
- * Set a value in the cache
312
- *
313
- * @param key - Cache key
314
- * @param data - Data to cache
358
+ * Get the 3-state layered view of a metadata item: `code` (the packaged
359
+ * artifact baseline), `overlay` (the tenant customisation row alone) and
360
+ * `effective` (the merged value the runtime sees).
361
+ *
362
+ * Reads **`GET /meta/:type/:name/layers`** — the path the framework declares
363
+ * for this projection, with a response schema of its own
364
+ * (`GetMetaItemLayeredResponseSchema`, objectstack#5882 ruling B). It used to
365
+ * be reached by hanging a `layers` flag on the ordinary item read, which made
366
+ * one route answer two unrelated representations while `packages/spec`
367
+ * declared only one of them. That spelling still answers this same body
368
+ * inside its deprecation window (the response carries RFC 9745
369
+ * `Deprecation: true` and an RFC 8288 `Link: rel="successor-version"` back to
370
+ * this path) and is scheduled for removal upstream, so nothing here may
371
+ * depend on it — the repo-wide ratchet is
372
+ * `scripts/__tests__/layered-read-declared-path-4016.test.ts`.
373
+ *
374
+ * The request is built here rather than delegated to `@objectstack/client`
375
+ * because the SDK expresses no layered read in EITHER spelling: the
376
+ * framework's REST route ledger records this route as `server-only`,
377
+ * "consumed by objectui over plain HTTP", and whether the SDK should express
378
+ * it is an open upstream product call.
379
+ *
380
+ * One behaviour delta rides along with the path, and it is the server's
381
+ * choice rather than ours: the retired flag FELL THROUGH to the plain item
382
+ * read on a backend whose protocol implementation had no layered support,
383
+ * answering the `{ type, name, item }` envelope. A dedicated path refuses to
384
+ * answer a different resource under this one's declared shape, so it returns
385
+ * 501 `NOT_IMPLEMENTED` instead — which surfaces here as a thrown error
386
+ * rather than a view with `code` and `overlay` silently blank.
315
387
  */
316
- set(key, data) {
317
- const now = Date.now();
318
- if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
319
- this.evictLRU();
388
+ async layered(type, name, options = {}) {
389
+ const qs = options.packageId ? `?package=${encodeURIComponent(options.packageId)}` : "";
390
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/layers${qs}`;
391
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
392
+ if (res.status === 404) {
393
+ return { code: null, overlay: null, overlayScope: null, effective: null };
320
394
  }
321
- this.cache.set(key, {
322
- data,
323
- timestamp: now,
324
- accessCount: 1,
325
- lastAccessed: now
326
- });
395
+ if (!res.ok) throw await parseError(res);
396
+ const body = await res.json();
397
+ const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
398
+ if (!hasEnvelope) {
399
+ return {
400
+ code: null,
401
+ overlay: null,
402
+ overlayScope: null,
403
+ effective: body
404
+ };
405
+ }
406
+ return {
407
+ code: body.code ?? null,
408
+ overlay: body.overlay ?? null,
409
+ overlayScope: body.overlayScope ?? null,
410
+ effective: body.effective ?? null,
411
+ ...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
412
+ // ADR-0010 Phase 4 — pass through the metadata-protection envelope so
413
+ // the editor can render lock affordances without a second round trip.
414
+ ...body.lock !== void 0 ? { lock: body.lock } : {},
415
+ ...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
416
+ ...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
417
+ ...body.lockDocsUrl !== void 0 ? { lockDocsUrl: body.lockDocsUrl } : {},
418
+ ...body.provenance !== void 0 ? { provenance: body.provenance } : {},
419
+ ...body.packageId !== void 0 ? { packageId: body.packageId } : {},
420
+ ...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
421
+ ...body.editable !== void 0 ? { editable: body.editable } : {},
422
+ ...body.deletable !== void 0 ? { deletable: body.deletable } : {},
423
+ ...body.resettable !== void 0 ? { resettable: body.resettable } : {}
424
+ };
327
425
  }
328
426
  /**
329
- * Evict the least recently used entry
427
+ * Cross-type sweep of load-time validation results — calls
428
+ * `GET /meta/diagnostics`. Returns every entry the framework
429
+ * considers invalid (or, when `severity: 'warning'`, also entries
430
+ * with only warnings).
431
+ *
432
+ * Used by the Studio's governance overview page and by the
433
+ * directory page to show "N invalid" badges per metadata type.
330
434
  */
331
- evictLRU() {
332
- const firstKey = this.cache.keys().next().value;
333
- if (firstKey !== void 0) {
334
- this.cache.delete(firstKey);
335
- this.stats.evictions++;
435
+ async diagnostics(options = {}) {
436
+ const params = [];
437
+ if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
438
+ if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
439
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
440
+ const qs = params.length ? `?${params.join("&")}` : "";
441
+ const url = `${this.base}/diagnostics${qs}`;
442
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
443
+ if (res.status === 404) {
444
+ return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
336
445
  }
446
+ if (!res.ok) throw await parseError(res);
447
+ const data = await res.json();
448
+ return {
449
+ entries: Array.isArray(data.entries) ? data.entries : [],
450
+ total: typeof data.total === "number" ? data.total : 0,
451
+ scannedTypes: typeof data.scannedTypes === "number" ? data.scannedTypes : 0,
452
+ scannedItems: typeof data.scannedItems === "number" ? data.scannedItems : 0,
453
+ stats: data.stats && typeof data.stats === "object" ? data.stats : {}
454
+ };
337
455
  }
338
456
  /**
339
- * Invalidate a specific cache entry or all entries
340
- *
341
- * @param key - Optional key to invalidate. If omitted, invalidates all entries
457
+ * Find every metadata item that references this one (Phase 3a). Useful
458
+ * for pre-delete impact analysis: "Are any views pointing at this
459
+ * object before I drop it?".
342
460
  */
343
- invalidate(key) {
344
- if (key) {
345
- this.cache.delete(key);
346
- } else {
347
- this.cache.clear();
348
- }
461
+ async references(type, name) {
462
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
463
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
464
+ if (res.status === 404) return [];
465
+ if (!res.ok) throw await parseError(res);
466
+ const data = await res.json();
467
+ return Array.isArray(data) ? data : data?.items ?? [];
349
468
  }
350
469
  /**
351
- * Clear all cache entries and reset statistics
470
+ * Reset a metadata customization overlay back to the artifact default.
471
+ * Idempotent: returns the result even when no overlay row existed.
472
+ * Pass `state: 'draft'` to discard the pending draft only (keeps the
473
+ * published overlay intact) — useful for a Studio "Discard draft" button.
474
+ */
475
+ async reset(type, name, options = {}) {
476
+ const qs = options.state === "draft" ? "?state=draft" : "";
477
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
478
+ const headers = { ...this.headers };
479
+ if (options.ifMatch) headers["If-Match"] = options.ifMatch;
480
+ const res = await this.fetchImpl(url, { method: "DELETE", headers });
481
+ if (!res.ok) throw await parseError(res);
482
+ return await res.json();
483
+ }
484
+ /**
485
+ * Promote the pending draft of an item to the active overlay
486
+ * (POST `/meta/:type/:name/publish`). Returns
487
+ * `{ success, version, seq, message }`. Throws a `404 no_draft` if
488
+ * nothing is pending and `409 metadata_conflict` if the published
489
+ * overlay moved while the draft was sitting.
352
490
  */
353
- clear() {
354
- this.cache.clear();
355
- this.inflight.clear();
356
- this.stats = {
357
- hits: 0,
358
- misses: 0,
359
- evictions: 0,
360
- coalesced: 0
491
+ async publish(type, name, options = {}) {
492
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
493
+ const headers = {
494
+ ...this.headers,
495
+ "Content-Type": "application/json"
361
496
  };
497
+ const res = await this.fetchImpl(url, {
498
+ method: "POST",
499
+ headers,
500
+ body: JSON.stringify(options.message ? { message: options.message } : {})
501
+ });
502
+ if (!res.ok) throw await parseError(res);
503
+ return await res.json();
362
504
  }
363
505
  /**
364
- * Get cache statistics
365
- *
366
- * @returns Cache statistics including hit rate
506
+ * Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
507
+ * The server reads the history row at `toVersion`, writes its body back
508
+ * as the active overlay with `operation_type='revert'`. Returns
509
+ * `{ success, version, seq, restoredFromVersion, message }`. Throws
510
+ * `404 version_not_found` for unknown versions and `409 version_not_restorable`
511
+ * when the target version is a delete tombstone.
367
512
  */
368
- getStats() {
369
- const total = this.stats.hits + this.stats.misses;
370
- const hitRate = total > 0 ? this.stats.hits / total : 0;
371
- return {
372
- size: this.cache.size,
373
- maxSize: this.maxSize,
374
- hits: this.stats.hits,
375
- misses: this.stats.misses,
376
- evictions: this.stats.evictions,
377
- coalesced: this.stats.coalesced,
378
- hitRate
513
+ async rollback(type, name, toVersion, options = {}) {
514
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
515
+ const headers = {
516
+ ...this.headers,
517
+ "Content-Type": "application/json"
379
518
  };
519
+ const res = await this.fetchImpl(url, {
520
+ method: "POST",
521
+ headers,
522
+ body: JSON.stringify({
523
+ toVersion,
524
+ ...options.message ? { message: options.message } : {}
525
+ })
526
+ });
527
+ if (!res.ok) throw await parseError(res);
528
+ return await res.json();
380
529
  }
381
530
  /**
382
- * Get a cached value synchronously without triggering a fetch.
383
- * Returns undefined if not in cache or expired.
531
+ * Compute a structured top-level key diff between two history versions
532
+ * (GET `/meta/:type/:name/diff?from=&to=`). Returns
533
+ * `{ added, removed, changed }` where each entry carries a `path` and
534
+ * the relevant value(s). Useful for "what changed in this draft?" and
535
+ * pre-rollback previews.
384
536
  */
385
- getCachedSync(key) {
386
- const entry = this.cache.get(key);
387
- if (!entry) return void 0;
388
- if (this.ttl > 0 && Date.now() - entry.timestamp > this.ttl) {
389
- this.cache.delete(key);
390
- return void 0;
391
- }
392
- this.cache.delete(key);
393
- this.cache.set(key, entry);
394
- this.stats.hits++;
395
- return entry.data;
537
+ async diff(type, name, fromVersion, toVersion) {
538
+ const params = new URLSearchParams();
539
+ if (fromVersion !== void 0) params.set("from", String(fromVersion));
540
+ if (toVersion !== void 0) params.set("to", String(toVersion));
541
+ const qs = params.toString();
542
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ""}`;
543
+ const res = await this.fetchImpl(url, {
544
+ method: "GET",
545
+ headers: this.headers,
546
+ cache: "no-store"
547
+ });
548
+ if (!res.ok) throw await parseError(res);
549
+ return await res.json();
550
+ }
551
+ /** Fetch the durable history (change log) for one metadata item. */
552
+ async history(type, name, options = {}) {
553
+ const params = new URLSearchParams();
554
+ if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
555
+ if (options.limit !== void 0) params.set("limit", String(options.limit));
556
+ const qs = params.toString();
557
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
558
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
559
+ if (!res.ok) throw await parseError(res);
560
+ return await res.json();
396
561
  }
397
562
  /**
398
- * Check if a key exists in the cache (and is not expired)
399
- *
400
- * @param key - Cache key to check
401
- * @returns true if the key exists and is not expired
563
+ * Fetch the protection-audit trail for one metadata item
564
+ * (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
565
+ * delete/reset attempt — allowed, denied, or forced — so the
566
+ * Studio "审计日志 / Audit log" tab can render who tried what
567
+ * and whether a lock blocked it.
402
568
  */
403
- has(key) {
404
- const cached = this.cache.get(key);
405
- if (!cached) {
406
- return false;
407
- }
408
- const age = Date.now() - cached.timestamp;
409
- if (age >= this.ttl) {
410
- this.cache.delete(key);
411
- return false;
412
- }
413
- return true;
569
+ async audit(type, name, options = {}) {
570
+ const params = new URLSearchParams();
571
+ if (options.limit !== void 0) params.set("limit", String(options.limit));
572
+ const qs = params.toString();
573
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ""}`;
574
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
575
+ if (!res.ok) throw await parseError(res);
576
+ return await res.json();
414
577
  }
415
578
  };
416
579
 
417
- // src/metadata-client.ts
418
- var META_PREFIX = "/meta";
419
- var API_PREFIX = "/api/v1";
420
- function buildBase(config) {
421
- const trimmed = config.baseUrl.replace(/\/+$/, "");
422
- const scoped = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
423
- if (/\/api\/v\d+$/i.test(trimmed)) {
424
- const stripped = trimmed.replace(/\/api\/v\d+$/i, "");
425
- const scopedNoApi = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
426
- return `${stripped}${scopedNoApi}`;
427
- }
428
- return `${trimmed}${scoped}`;
580
+ // ../types/src/index.ts
581
+ var import_spec = require("@objectstack/spec");
582
+
583
+ // ../types/src/error-code.ts
584
+ function errorCodeIs(err, code) {
585
+ const raw = err?.code;
586
+ if (typeof raw !== "string" || raw.length === 0) return false;
587
+ return raw.toUpperCase() === code.toUpperCase();
429
588
  }
430
- async function parseError(res) {
431
- let body;
432
- try {
433
- body = await res.json();
434
- } catch {
435
- body = await res.text().catch(() => void 0);
436
- }
437
- const bodyObj = body && typeof body === "object" ? body : void 0;
438
- const errNode = bodyObj?.error;
439
- const message = (errNode && typeof errNode === "object" ? errNode.message : errNode) ?? bodyObj?.message ?? `Metadata request failed: ${res.status} ${res.statusText}`;
440
- const err = new Error(String(message));
441
- err.status = res.status;
442
- err.body = body;
443
- const details = errNode && typeof errNode === "object" ? errNode.details : void 0;
444
- const issues = details?.issues ?? (errNode && typeof errNode === "object" ? errNode.issues : void 0) ?? bodyObj?.issues;
445
- if (Array.isArray(issues)) err.issues = issues;
446
- const code = details?.code ?? (errNode && typeof errNode === "object" ? errNode.code : void 0) ?? bodyObj?.code;
447
- if (code != null) err.code = String(code);
448
- return err;
589
+ function errorCodeIsAnyOf(err, codes) {
590
+ return codes.some((c) => errorCodeIs(err, c));
449
591
  }
450
- var MetadataClient = class _MetadataClient {
451
- constructor(config) {
452
- __publicField(this, "base");
453
- __publicField(this, "fetchImpl");
454
- __publicField(this, "headers");
455
- /** ADR-0037: when true, reads render the draft-overlaid world. */
456
- __publicField(this, "previewDrafts");
457
- this.base = buildBase(config);
458
- this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
459
- this.headers = { Accept: "application/json", ...config.headers ?? {} };
460
- this.previewDrafts = config.previewDrafts === true;
461
- }
462
- /** Update the client's environment scope at runtime. */
463
- withEnvironment(environmentId) {
464
- return new _MetadataClient({
465
- baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
466
- environmentId,
467
- fetch: this.fetchImpl,
468
- headers: this.headers,
469
- previewDrafts: this.previewDrafts
470
- });
471
- }
472
- /**
473
- * Derive a client whose reads render the draft-overlaid world (or not).
474
- * Same base/fetch/headers; used by the app-shell to switch the whole
475
- * renderer tree into ADR-0037 preview mode off one URL flag.
476
- */
477
- withPreviewDrafts(previewDrafts) {
478
- if (previewDrafts === this.previewDrafts) return this;
479
- const envMatch = this.base.match(/\/api\/v\d+\/environments\/([^/]+)\/meta$/);
480
- return new _MetadataClient({
481
- baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
482
- ...envMatch ? { environmentId: decodeURIComponent(envMatch[1]) } : {},
483
- fetch: this.fetchImpl,
484
- headers: this.headers,
485
- previewDrafts
486
- });
592
+
593
+ // ../core/src/utils/filter-converter.ts
594
+ var import_ui = require("@objectstack/spec/ui");
595
+ var FilterOperatorError = class extends Error {
596
+ constructor(message) {
597
+ super(message);
598
+ __publicField(this, "code", "INVALID_FILTER");
599
+ __publicField(this, "httpStatus", 400);
600
+ this.name = "FilterOperatorError";
487
601
  }
488
- /** List all registered metadata types (returns the registry rows). */
489
- async listTypes() {
490
- const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
491
- if (!res.ok) throw await parseError(res);
492
- const data = await res.json();
493
- if (Array.isArray(data)) return data;
494
- if (data && Array.isArray(data.entries)) return data.entries;
495
- if (data && Array.isArray(data.items)) return data.items;
496
- if (data && Array.isArray(data.types)) {
497
- return data.types.map((t) => ({ type: t }));
602
+ };
603
+ function convertOperatorToAST(operator) {
604
+ const operatorMap = {
605
+ "$eq": "=",
606
+ "$ne": "!=",
607
+ "$gt": ">",
608
+ "$gte": ">=",
609
+ "$lt": "<",
610
+ "$lte": "<=",
611
+ "$in": "in",
612
+ "$nin": "nin",
613
+ "$notin": "nin",
614
+ "$between": "between",
615
+ "$contains": "contains",
616
+ "$notContains": "notcontains",
617
+ "$notcontains": "notcontains",
618
+ "$startsWith": "startswith",
619
+ "$startswith": "startswith",
620
+ "$endsWith": "endswith",
621
+ "$endswith": "endswith"
622
+ };
623
+ return operatorMap[operator] || null;
624
+ }
625
+ function convertFiltersToAST(filter) {
626
+ const conditions = [];
627
+ for (const [field, value] of Object.entries(filter)) {
628
+ if (value === null || value === void 0) continue;
629
+ if (typeof value === "object" && !Array.isArray(value)) {
630
+ for (const [operator, operatorValue] of Object.entries(value)) {
631
+ if (operator === "$regex") {
632
+ throw new FilterOperatorError(
633
+ `[ObjectUI] The '$regex' filter operator is not supported. It used to be converted to 'contains', which matches a literal substring rather than a pattern \u2014 a different result, not a degraded one. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. Use $contains, $startsWith or $endsWith.`
634
+ );
635
+ }
636
+ if (operator === "$null") {
637
+ conditions.push([field, operatorValue ? "is_null" : "is_not_null", true]);
638
+ continue;
639
+ }
640
+ if (operator === "$exists") {
641
+ conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
642
+ continue;
643
+ }
644
+ const astOperator = convertOperatorToAST(operator);
645
+ if (astOperator) {
646
+ conditions.push([field, astOperator, operatorValue]);
647
+ } else {
648
+ throw new FilterOperatorError(
649
+ `[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, $notContains, $startsWith, $endsWith, $null, $exists. If you need exact object matching, use the value directly without an operator.`
650
+ );
651
+ }
652
+ }
653
+ } else {
654
+ conditions.push([field, "=", value]);
498
655
  }
499
- return [];
500
656
  }
501
- /** List items of a metadata type (e.g. `object`, `field`, `view`). */
502
- async list(type, options = {}) {
503
- const params = [];
504
- if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
505
- if (this.previewDrafts) params.push("preview=draft");
506
- const qs = params.length ? `?${params.join("&")}` : "";
507
- const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
508
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
509
- if (!res.ok) throw await parseError(res);
510
- const data = await res.json();
511
- if (Array.isArray(data)) return data;
512
- if (data && Array.isArray(data.items)) {
513
- return data.items;
514
- }
515
- return [];
657
+ if (conditions.length === 0) {
658
+ return filter;
516
659
  }
517
- /**
518
- * List pending DRAFT items (ADR-0033) — what an AI authored but nobody
519
- * published yet. `list()` only sees published/active metadata, so a
520
- * just-built app package looks empty there; this surfaces the drafts so the
521
- * console can show a "pending changes" view and draft-aware package contents.
522
- * Optionally narrow by `packageId` and/or `type`. Returns light headers
523
- * (no body) carrying `packageId` for grouping.
524
- */
525
- async listDrafts(options = {}) {
526
- const params = [];
527
- if (options.packageId) params.push(`packageId=${encodeURIComponent(options.packageId)}`);
528
- if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
529
- const qs = params.length ? `?${params.join("&")}` : "";
530
- const url = `${this.base}/_drafts${qs}`;
531
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
532
- if (!res.ok) throw await parseError(res);
533
- const data = await res.json();
534
- if (Array.isArray(data)) return data;
535
- return data?.drafts ?? data?.data?.drafts ?? [];
660
+ if (conditions.length === 1) {
661
+ return conditions[0];
536
662
  }
537
- /**
538
- * Get a single metadata item. Returns the unwrapped item content
539
- * (matching the framework REST handler which calls `res.json(item)`).
540
- * Returns `null` on 404 to keep the call-site ergonomic.
541
- */
542
- async get(type, name, options = {}) {
543
- const params = [];
544
- if (options.state === "draft") params.push("state=draft");
545
- else if (this.previewDrafts) params.push("preview=draft");
546
- if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
547
- const qs = params.length ? `?${params.join("&")}` : "";
548
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
549
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
550
- if (res.status === 404) return null;
551
- if (!res.ok) throw await parseError(res);
552
- return await res.json();
663
+ return ["and", ...conditions];
664
+ }
665
+
666
+ // ../core/src/adapters/batchTransaction.ts
667
+ function idOf(rec) {
668
+ if (rec == null) return void 0;
669
+ return rec.id ?? rec._id ?? rec.recordId;
670
+ }
671
+ function resolveRefs(data, results) {
672
+ const out = {};
673
+ for (const [key, value] of Object.entries(data)) {
674
+ if (value && typeof value === "object" && !Array.isArray(value) && typeof value.$ref === "number") {
675
+ const refIndex = value.$ref;
676
+ const id = idOf(results[refIndex]);
677
+ if (id === void 0) {
678
+ throw new Error(
679
+ `batchTransaction: field "${key}" references operation ${refIndex}, which has not produced an id (forward or invalid $ref)`
680
+ );
681
+ }
682
+ out[key] = id;
683
+ } else {
684
+ out[key] = value;
685
+ }
553
686
  }
554
- /**
555
- * Read the pending draft body for an item (`?state=draft`). Returns
556
- * `null` when there is no draft pending. Draft reads do NOT fall
557
- * back to the published overlay or the artifact registry — a `null`
558
- * unambiguously means "nothing to publish".
559
- *
560
- * Note: the framework wraps draft responses in an envelope
561
- * `{ type, name, item }` (matching `getMetaItem`); callers should
562
- * read `.item` to get the body. The legacy `get()` returns the
563
- * unwrapped body, so this method preserves that asymmetry by
564
- * returning whatever the server sent.
565
- */
566
- async getDraft(type, name, options = {}) {
567
- return this.get(type, name, { state: "draft", ...options.packageId ? { packageId: options.packageId } : {} });
687
+ return out;
688
+ }
689
+ async function emulateBatchTransaction(dataSource, operations) {
690
+ const results = [];
691
+ const created = [];
692
+ try {
693
+ for (const op of operations) {
694
+ const action = op.action ?? "create";
695
+ if (action === "create") {
696
+ const record = await dataSource.create(
697
+ op.object,
698
+ resolveRefs(op.data ?? {}, results)
699
+ );
700
+ results.push(record);
701
+ const id = idOf(record);
702
+ if (id) created.push({ object: op.object, id });
703
+ } else if (action === "update") {
704
+ if (op.id == null) {
705
+ throw new Error(`batchTransaction: update on "${op.object}" requires an id`);
706
+ }
707
+ results.push(
708
+ await dataSource.update(
709
+ op.object,
710
+ op.id,
711
+ resolveRefs(op.data ?? {}, results)
712
+ )
713
+ );
714
+ } else {
715
+ if (op.id == null) {
716
+ throw new Error(`batchTransaction: delete on "${op.object}" requires an id`);
717
+ }
718
+ results.push(await dataSource.delete(op.object, op.id));
719
+ }
720
+ }
721
+ return { results };
722
+ } catch (err) {
723
+ if (created.length > 0) {
724
+ await Promise.allSettled(
725
+ [...created].reverse().map((c) => dataSource.delete(c.object, c.id))
726
+ );
727
+ }
728
+ throw err;
568
729
  }
730
+ }
731
+
732
+ // ../core/src/utils/reference-keys.ts
733
+ function normalizeFieldReferenceKeys(fieldDef) {
734
+ if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
735
+ const f = fieldDef;
736
+ const target = f.reference_to ?? f.reference ?? f.referenceTo;
737
+ if (target == null || target === "") return fieldDef;
738
+ if (f.reference_to === void 0) f.reference_to = target;
739
+ if (f.reference === void 0) f.reference = target;
740
+ return fieldDef;
741
+ }
742
+ function normalizeSchemaReferenceKeys(schema) {
743
+ const fields = schema && typeof schema === "object" ? schema.fields : null;
744
+ if (!fields || typeof fields !== "object") return schema;
745
+ const defs = Array.isArray(fields) ? fields : Object.values(fields);
746
+ for (const def of defs) normalizeFieldReferenceKeys(def);
747
+ return schema;
748
+ }
749
+
750
+ // src/cache/MetadataCache.ts
751
+ var MetadataCache = class {
569
752
  /**
570
- * Save (PUT) a metadata item. The framework accepts both the bare
571
- * item payload and the `{ item: ... }` / `{ metadata: ... }`
572
- * envelopes; we send bare for consistency. Pass `mode: 'draft'` to
573
- * stage the change without publishing (Studio's "Save" button).
753
+ * Create a new MetadataCache instance
754
+ *
755
+ * @param options - Configuration options
756
+ * @param options.maxSize - Maximum number of entries (default: 100)
757
+ * @param options.ttl - Time to live in milliseconds (default: 5 minutes)
574
758
  */
575
- async save(type, name, item, options = {}) {
576
- if (!name || !String(name).trim()) {
577
- throw new Error(
578
- `MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
579
- );
580
- }
581
- const params = [];
582
- if (options.force) params.push("force=true");
583
- if (options.mode === "draft") params.push("mode=draft");
584
- if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
585
- const qs = params.length ? `?${params.join("&")}` : "";
586
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
587
- const headers = {
588
- ...this.headers,
589
- "Content-Type": "application/json"
759
+ constructor(options = {}) {
760
+ __publicField(this, "cache");
761
+ __publicField(this, "inflight");
762
+ __publicField(this, "maxSize");
763
+ __publicField(this, "ttl");
764
+ __publicField(this, "stats");
765
+ this.cache = /* @__PURE__ */ new Map();
766
+ this.inflight = /* @__PURE__ */ new Map();
767
+ this.maxSize = options.maxSize || 100;
768
+ this.ttl = options.ttl || 5 * 60 * 1e3;
769
+ this.stats = {
770
+ hits: 0,
771
+ misses: 0,
772
+ evictions: 0,
773
+ coalesced: 0
590
774
  };
591
- if (options.ifMatch) headers["If-Match"] = options.ifMatch;
592
- if (options.actor) headers["X-Actor"] = options.actor;
593
- const res = await this.fetchImpl(url, {
594
- method: "PUT",
595
- headers,
596
- body: JSON.stringify(item)
597
- });
598
- if (!res.ok) throw await parseError(res);
599
- return await res.json();
600
775
  }
601
776
  /**
602
- * Publish a single pending draft BY REFERENCE — promotes the draft row for
603
- * `(type, name)` into the active overlay and drops the draft. Works for ANY
604
- * draft, including ones with no `packageId` binding (which the package-scoped
605
- * `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
606
- * exact set returned by {@link listDrafts} without needing a package.
607
- *
608
- * Returns the server result. For `seed` drafts the protocol also materializes
609
- * the rows and reports under `seedApplied` — a data problem never fails the
610
- * publish, so callers should check `seedApplied?.success` and warn the user
611
- * rather than assume the data went live.
777
+ * Get a value from cache or fetch it using the provided fetcher function
778
+ *
779
+ * @param key - Cache key
780
+ * @param fetcher - Async function to fetch data if not in cache
781
+ * @returns Promise resolving to the cached or fetched data
612
782
  */
613
- async publishDraft(type, name) {
614
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
615
- const res = await this.fetchImpl(url, {
616
- method: "POST",
617
- headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
618
- body: "{}"
619
- });
620
- if (!res.ok) throw await parseError(res);
621
- const body = await res.json().catch(() => ({}));
622
- const inner = body?.data && typeof body.data === "object" ? body.data : body;
623
- return inner;
783
+ async get(key, fetcher) {
784
+ const now = Date.now();
785
+ const cached = this.cache.get(key);
786
+ if (cached) {
787
+ const age = now - cached.timestamp;
788
+ if (age < this.ttl) {
789
+ cached.accessCount++;
790
+ cached.lastAccessed = now;
791
+ this.stats.hits++;
792
+ this.cache.delete(key);
793
+ this.cache.set(key, cached);
794
+ return cached.data;
795
+ } else {
796
+ this.cache.delete(key);
797
+ }
798
+ }
799
+ const existing = this.inflight.get(key);
800
+ if (existing) {
801
+ this.stats.coalesced++;
802
+ return existing;
803
+ }
804
+ this.stats.misses++;
805
+ const promise = (async () => {
806
+ try {
807
+ const data = await fetcher();
808
+ this.set(key, data);
809
+ return data;
810
+ } finally {
811
+ this.inflight.delete(key);
812
+ }
813
+ })();
814
+ this.inflight.set(key, promise);
815
+ return promise;
624
816
  }
625
817
  /**
626
- * Get the 3-state layered view of a metadata item (Phase 3a). Returns
627
- * `code` (the artifact / fallback default), `overlay` (the saved
628
- * customisation, if any), and `effective` (what the runtime sees).
818
+ * Prime the cache with a pre-fetched value. Useful when a bulk endpoint
819
+ * (e.g. list of all object schemas) returns data that would otherwise
820
+ * be fetched again per item.
629
821
  */
630
- async layered(type, name, options = {}) {
631
- const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : "";
632
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
633
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
634
- if (res.status === 404) {
635
- return { code: null, overlay: null, overlayScope: null, effective: null };
636
- }
637
- if (!res.ok) throw await parseError(res);
638
- const body = await res.json();
639
- const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
640
- if (!hasEnvelope) {
641
- return {
642
- code: null,
643
- overlay: null,
644
- overlayScope: null,
645
- effective: body
646
- };
647
- }
648
- return {
649
- code: body.code ?? null,
650
- overlay: body.overlay ?? null,
651
- overlayScope: body.overlayScope ?? null,
652
- effective: body.effective ?? null,
653
- ...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
654
- // ADR-0010 Phase 4 — pass through the metadata-protection envelope so
655
- // the editor can render lock affordances without a second round trip.
656
- ...body.lock !== void 0 ? { lock: body.lock } : {},
657
- ...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
658
- ...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
659
- ...body.lockDocsUrl !== void 0 ? { lockDocsUrl: body.lockDocsUrl } : {},
660
- ...body.provenance !== void 0 ? { provenance: body.provenance } : {},
661
- ...body.packageId !== void 0 ? { packageId: body.packageId } : {},
662
- ...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
663
- ...body.editable !== void 0 ? { editable: body.editable } : {},
664
- ...body.deletable !== void 0 ? { deletable: body.deletable } : {},
665
- ...body.resettable !== void 0 ? { resettable: body.resettable } : {}
666
- };
822
+ prime(key, data) {
823
+ this.set(key, data);
667
824
  }
668
825
  /**
669
- * Cross-type sweep of load-time validation results — calls
670
- * `GET /meta/diagnostics`. Returns every entry the framework
671
- * considers invalid (or, when `severity: 'warning'`, also entries
672
- * with only warnings).
673
- *
674
- * Used by the Studio's governance overview page and by the
675
- * directory page to show "N invalid" badges per metadata type.
826
+ * Set a value in the cache
827
+ *
828
+ * @param key - Cache key
829
+ * @param data - Data to cache
676
830
  */
677
- async diagnostics(options = {}) {
678
- const params = [];
679
- if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
680
- if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
681
- if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
682
- const qs = params.length ? `?${params.join("&")}` : "";
683
- const url = `${this.base}/diagnostics${qs}`;
684
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
685
- if (res.status === 404) {
686
- return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
831
+ set(key, data) {
832
+ const now = Date.now();
833
+ if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
834
+ this.evictLRU();
687
835
  }
688
- if (!res.ok) throw await parseError(res);
689
- const data = await res.json();
690
- return {
691
- entries: Array.isArray(data.entries) ? data.entries : [],
692
- total: typeof data.total === "number" ? data.total : 0,
693
- scannedTypes: typeof data.scannedTypes === "number" ? data.scannedTypes : 0,
694
- scannedItems: typeof data.scannedItems === "number" ? data.scannedItems : 0,
695
- stats: data.stats && typeof data.stats === "object" ? data.stats : {}
696
- };
836
+ this.cache.set(key, {
837
+ data,
838
+ timestamp: now,
839
+ accessCount: 1,
840
+ lastAccessed: now
841
+ });
697
842
  }
698
843
  /**
699
- * Find every metadata item that references this one (Phase 3a). Useful
700
- * for pre-delete impact analysis: "Are any views pointing at this
701
- * object before I drop it?".
844
+ * Evict the least recently used entry
702
845
  */
703
- async references(type, name) {
704
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
705
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
706
- if (res.status === 404) return [];
707
- if (!res.ok) throw await parseError(res);
708
- const data = await res.json();
709
- return Array.isArray(data) ? data : data?.items ?? [];
846
+ evictLRU() {
847
+ const firstKey = this.cache.keys().next().value;
848
+ if (firstKey !== void 0) {
849
+ this.cache.delete(firstKey);
850
+ this.stats.evictions++;
851
+ }
710
852
  }
711
853
  /**
712
- * Reset a metadata customization overlay back to the artifact default.
713
- * Idempotent: returns the result even when no overlay row existed.
714
- * Pass `state: 'draft'` to discard the pending draft only (keeps the
715
- * published overlay intact) — useful for a Studio "Discard draft" button.
854
+ * Invalidate a specific cache entry or all entries
855
+ *
856
+ * @param key - Optional key to invalidate. If omitted, invalidates all entries
716
857
  */
717
- async reset(type, name, options = {}) {
718
- const qs = options.state === "draft" ? "?state=draft" : "";
719
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
720
- const headers = { ...this.headers };
721
- if (options.ifMatch) headers["If-Match"] = options.ifMatch;
722
- if (options.actor) headers["X-Actor"] = options.actor;
723
- const res = await this.fetchImpl(url, { method: "DELETE", headers });
724
- if (!res.ok) throw await parseError(res);
725
- return await res.json();
858
+ invalidate(key) {
859
+ if (key) {
860
+ this.cache.delete(key);
861
+ } else {
862
+ this.cache.clear();
863
+ }
726
864
  }
727
865
  /**
728
- * Promote the pending draft of an item to the active overlay
729
- * (POST `/meta/:type/:name/publish`). Returns
730
- * `{ success, version, seq, message }`. Throws a `404 no_draft` if
731
- * nothing is pending and `409 metadata_conflict` if the published
732
- * overlay moved while the draft was sitting.
866
+ * Clear all cache entries and reset statistics
733
867
  */
734
- async publish(type, name, options = {}) {
735
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
736
- const headers = {
737
- ...this.headers,
738
- "Content-Type": "application/json"
868
+ clear() {
869
+ this.cache.clear();
870
+ this.inflight.clear();
871
+ this.stats = {
872
+ hits: 0,
873
+ misses: 0,
874
+ evictions: 0,
875
+ coalesced: 0
739
876
  };
740
- if (options.actor) headers["X-Actor"] = options.actor;
741
- const res = await this.fetchImpl(url, {
742
- method: "POST",
743
- headers,
744
- body: JSON.stringify(options.message ? { message: options.message } : {})
745
- });
746
- if (!res.ok) throw await parseError(res);
747
- return await res.json();
748
877
  }
749
878
  /**
750
- * Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
751
- * The server reads the history row at `toVersion`, writes its body back
752
- * as the active overlay with `operation_type='revert'`. Returns
753
- * `{ success, version, seq, restoredFromVersion, message }`. Throws
754
- * `404 version_not_found` for unknown versions and `409 version_not_restorable`
755
- * when the target version is a delete tombstone.
879
+ * Get cache statistics
880
+ *
881
+ * @returns Cache statistics including hit rate
756
882
  */
757
- async rollback(type, name, toVersion, options = {}) {
758
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
759
- const headers = {
760
- ...this.headers,
761
- "Content-Type": "application/json"
883
+ getStats() {
884
+ const total = this.stats.hits + this.stats.misses;
885
+ const hitRate = total > 0 ? this.stats.hits / total : 0;
886
+ return {
887
+ size: this.cache.size,
888
+ maxSize: this.maxSize,
889
+ hits: this.stats.hits,
890
+ misses: this.stats.misses,
891
+ evictions: this.stats.evictions,
892
+ coalesced: this.stats.coalesced,
893
+ hitRate
762
894
  };
763
- if (options.actor) headers["X-Actor"] = options.actor;
764
- const res = await this.fetchImpl(url, {
765
- method: "POST",
766
- headers,
767
- body: JSON.stringify({
768
- toVersion,
769
- ...options.message ? { message: options.message } : {}
770
- })
771
- });
772
- if (!res.ok) throw await parseError(res);
773
- return await res.json();
774
895
  }
775
896
  /**
776
- * Compute a structured top-level key diff between two history versions
777
- * (GET `/meta/:type/:name/diff?from=&to=`). Returns
778
- * `{ added, removed, changed }` where each entry carries a `path` and
779
- * the relevant value(s). Useful for "what changed in this draft?" and
780
- * pre-rollback previews.
897
+ * Get a cached value synchronously without triggering a fetch.
898
+ * Returns undefined if not in cache or expired.
781
899
  */
782
- async diff(type, name, fromVersion, toVersion) {
783
- const params = new URLSearchParams();
784
- if (fromVersion !== void 0) params.set("from", String(fromVersion));
785
- if (toVersion !== void 0) params.set("to", String(toVersion));
786
- const qs = params.toString();
787
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ""}`;
788
- const res = await this.fetchImpl(url, {
789
- method: "GET",
790
- headers: this.headers,
791
- cache: "no-store"
792
- });
793
- if (!res.ok) throw await parseError(res);
794
- return await res.json();
795
- }
796
- /** Fetch the durable history (change log) for one metadata item. */
797
- async history(type, name, options = {}) {
798
- const params = new URLSearchParams();
799
- if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
800
- if (options.limit !== void 0) params.set("limit", String(options.limit));
801
- const qs = params.toString();
802
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
803
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
804
- if (!res.ok) throw await parseError(res);
805
- return await res.json();
900
+ getCachedSync(key) {
901
+ const entry = this.cache.get(key);
902
+ if (!entry) return void 0;
903
+ if (this.ttl > 0 && Date.now() - entry.timestamp > this.ttl) {
904
+ this.cache.delete(key);
905
+ return void 0;
906
+ }
907
+ this.cache.delete(key);
908
+ this.cache.set(key, entry);
909
+ this.stats.hits++;
910
+ return entry.data;
806
911
  }
807
912
  /**
808
- * Fetch the protection-audit trail for one metadata item
809
- * (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
810
- * delete/reset attempt — allowed, denied, or forced — so the
811
- * Studio "审计日志 / Audit log" tab can render who tried what
812
- * and whether a lock blocked it.
913
+ * Check if a key exists in the cache (and is not expired)
914
+ *
915
+ * @param key - Cache key to check
916
+ * @returns true if the key exists and is not expired
813
917
  */
814
- async audit(type, name, options = {}) {
815
- const params = new URLSearchParams();
816
- if (options.limit !== void 0) params.set("limit", String(options.limit));
817
- const qs = params.toString();
818
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ""}`;
819
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
820
- if (!res.ok) throw await parseError(res);
821
- return await res.json();
918
+ has(key) {
919
+ const cached = this.cache.get(key);
920
+ if (!cached) {
921
+ return false;
922
+ }
923
+ const age = Date.now() - cached.timestamp;
924
+ if (age >= this.ttl) {
925
+ this.cache.delete(key);
926
+ return false;
927
+ }
928
+ return true;
822
929
  }
823
930
  };
824
931
 
@@ -1009,91 +1116,6 @@ function isErrorType(error, errorClass) {
1009
1116
  return error instanceof errorClass;
1010
1117
  }
1011
1118
 
1012
- // src/cloud.ts
1013
- var CloudOperations = class {
1014
- constructor(getClient) {
1015
- __publicField(this, "getClient", getClient);
1016
- }
1017
- /**
1018
- * Deploy an application to the cloud.
1019
- */
1020
- async deploy(appId, config) {
1021
- const client = this.getClient();
1022
- const result = await client.cloud?.deploy?.(appId, config);
1023
- return result ?? { deploymentId: `deploy-${Date.now()}`, status: "pending" };
1024
- }
1025
- /**
1026
- * Get deployment status.
1027
- */
1028
- async getDeploymentStatus(deploymentId) {
1029
- const client = this.getClient();
1030
- const result = await client.cloud?.getDeployment?.(deploymentId);
1031
- return result ?? { status: "unknown" };
1032
- }
1033
- /**
1034
- * Search marketplace entries.
1035
- */
1036
- async searchMarketplace(query, category) {
1037
- const client = this.getClient();
1038
- const result = await client.cloud?.marketplace?.search?.({ query, category });
1039
- return result?.items ?? [];
1040
- }
1041
- /**
1042
- * Install a marketplace plugin.
1043
- */
1044
- async installPlugin(pluginId) {
1045
- const client = this.getClient();
1046
- const result = await client.cloud?.marketplace?.install?.(pluginId);
1047
- return result ?? { success: false };
1048
- }
1049
- };
1050
-
1051
- // src/contracts.ts
1052
- function validatePluginContract(contract) {
1053
- const errors = [];
1054
- const warnings = [];
1055
- if (!contract.name || contract.name.trim().length === 0) {
1056
- errors.push({ field: "name", message: "Plugin name is required", code: "MISSING_NAME" });
1057
- }
1058
- if (!contract.version || !/^\d+\.\d+\.\d+/.test(contract.version)) {
1059
- errors.push({ field: "version", message: "Valid semver version is required", code: "INVALID_VERSION" });
1060
- }
1061
- if (!contract.exports || contract.exports.length === 0) {
1062
- errors.push({ field: "exports", message: "At least one export is required", code: "NO_EXPORTS" });
1063
- }
1064
- if (contract.exports) {
1065
- const validTypes = ["component", "hook", "utility", "provider"];
1066
- for (const exp of contract.exports) {
1067
- if (!exp.name) {
1068
- errors.push({ field: "exports.name", message: "Export name is required", code: "MISSING_EXPORT_NAME" });
1069
- }
1070
- if (!validTypes.includes(exp.type)) {
1071
- errors.push({ field: "exports.type", message: `Invalid export type: ${exp.type}`, code: "INVALID_EXPORT_TYPE" });
1072
- }
1073
- }
1074
- }
1075
- if (!contract.permissions || contract.permissions.length === 0) {
1076
- warnings.push("No permissions declared \u2014 plugin will have minimal access");
1077
- }
1078
- return { valid: errors.length === 0, errors, warnings };
1079
- }
1080
- function generateContractManifest(contract) {
1081
- return {
1082
- $schema: "https://objectui.org/schemas/plugin-contract-v1.json",
1083
- name: contract.name,
1084
- version: contract.version,
1085
- peerDependencies: contract.peerDependencies ?? {},
1086
- exports: contract.exports.map((exp) => ({
1087
- name: exp.name,
1088
- type: exp.type,
1089
- description: exp.description ?? ""
1090
- })),
1091
- permissions: contract.permissions ?? [],
1092
- api: contract.api ?? {},
1093
- generatedAt: (/* @__PURE__ */ new Date()).toISOString()
1094
- };
1095
- }
1096
-
1097
1119
  // src/userState.ts
1098
1120
  var DEFAULT_RESOURCE = "sys_user_preference";
1099
1121
  function createObjectStackUserStateAdapter(options) {
@@ -1196,267 +1218,6 @@ function createObjectStackUserStateAdapter(options) {
1196
1218
  };
1197
1219
  }
1198
1220
 
1199
- // src/integration.ts
1200
- var IntegrationManager = class {
1201
- constructor() {
1202
- __publicField(this, "integrations", /* @__PURE__ */ new Map());
1203
- }
1204
- /**
1205
- * Register a new integration.
1206
- */
1207
- register(id, config) {
1208
- this.integrations.set(id, config);
1209
- }
1210
- /**
1211
- * Remove an integration.
1212
- */
1213
- unregister(id) {
1214
- this.integrations.delete(id);
1215
- }
1216
- /**
1217
- * Get all registered integrations.
1218
- */
1219
- getAll() {
1220
- return new Map(this.integrations);
1221
- }
1222
- /**
1223
- * Get integrations that match a specific event.
1224
- */
1225
- getForEvent(event) {
1226
- return Array.from(this.integrations.values()).filter(
1227
- (integration) => integration.enabled && integration.triggers?.some((t) => t.event === event)
1228
- );
1229
- }
1230
- /**
1231
- * Dispatch an event to all matching integrations.
1232
- * Returns results for each integration.
1233
- */
1234
- async dispatch(event, payload) {
1235
- const matching = this.getForEvent(event);
1236
- const results = [];
1237
- for (const [id, integration] of this.integrations) {
1238
- if (!matching.includes(integration)) continue;
1239
- try {
1240
- await this.send(integration, payload);
1241
- results.push({ id, success: true });
1242
- } catch (err) {
1243
- results.push({ id, success: false, error: err.message });
1244
- }
1245
- }
1246
- return results;
1247
- }
1248
- /**
1249
- * Send payload to a specific integration.
1250
- */
1251
- async send(integration, payload) {
1252
- switch (integration.provider) {
1253
- case "webhook": {
1254
- const cfg = integration.config;
1255
- const url = cfg.url;
1256
- try {
1257
- const parsed = new URL(url);
1258
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1259
- throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
1260
- }
1261
- } catch (e) {
1262
- if (e instanceof TypeError) {
1263
- throw new Error(`Invalid webhook URL: ${url}`);
1264
- }
1265
- throw e;
1266
- }
1267
- await fetch(url, {
1268
- method: cfg.method,
1269
- headers: {
1270
- "Content-Type": "application/json",
1271
- ...cfg.headers
1272
- },
1273
- body: JSON.stringify(payload)
1274
- });
1275
- break;
1276
- }
1277
- case "slack": {
1278
- const cfg = integration.config;
1279
- const url = cfg.webhookUrl;
1280
- try {
1281
- const parsed = new URL(url);
1282
- if (parsed.protocol !== "https:") {
1283
- throw new Error(`Invalid Slack webhook URL protocol: ${parsed.protocol}`);
1284
- }
1285
- } catch (e) {
1286
- if (e instanceof TypeError) {
1287
- throw new Error(`Invalid Slack webhook URL: ${url}`);
1288
- }
1289
- throw e;
1290
- }
1291
- await fetch(url, {
1292
- method: "POST",
1293
- headers: { "Content-Type": "application/json" },
1294
- body: JSON.stringify({
1295
- channel: cfg.channel,
1296
- username: cfg.username,
1297
- icon_emoji: cfg.iconEmoji,
1298
- text: JSON.stringify(payload)
1299
- })
1300
- });
1301
- break;
1302
- }
1303
- // Email and other providers would require server-side implementation
1304
- default:
1305
- break;
1306
- }
1307
- }
1308
- };
1309
-
1310
- // src/security.ts
1311
- var SecurityManager = class {
1312
- constructor(policy = {}) {
1313
- __publicField(this, "policy");
1314
- __publicField(this, "auditLog", []);
1315
- this.policy = policy;
1316
- }
1317
- /**
1318
- * Generate a CSP header string from the configuration.
1319
- */
1320
- generateCSPHeader() {
1321
- const csp = this.policy.csp;
1322
- if (!csp) return "";
1323
- const directives = [];
1324
- if (csp.scriptSrc?.length) directives.push(`script-src ${csp.scriptSrc.join(" ")}`);
1325
- if (csp.styleSrc?.length) directives.push(`style-src ${csp.styleSrc.join(" ")}`);
1326
- if (csp.imgSrc?.length) directives.push(`img-src ${csp.imgSrc.join(" ")}`);
1327
- if (csp.connectSrc?.length) directives.push(`connect-src ${csp.connectSrc.join(" ")}`);
1328
- if (csp.fontSrc?.length) directives.push(`font-src ${csp.fontSrc.join(" ")}`);
1329
- if (csp.frameSrc?.length) directives.push(`frame-src ${csp.frameSrc.join(" ")}`);
1330
- if (csp.reportUri) directives.push(`report-uri ${csp.reportUri}`);
1331
- return directives.join("; ");
1332
- }
1333
- /**
1334
- * Record an audit log entry.
1335
- */
1336
- recordAudit(entry) {
1337
- if (!this.policy.auditLog?.enabled) return;
1338
- if (this.policy.auditLog.events && !this.policy.auditLog.events.includes(entry.event)) return;
1339
- const fullEntry = {
1340
- ...entry,
1341
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1342
- };
1343
- this.auditLog.push(fullEntry);
1344
- const dest = this.policy.auditLog.destination ?? "console";
1345
- if (dest === "console" || dest === "both") {
1346
- console.info("[AUDIT]", fullEntry.event, fullEntry.userId, fullEntry.resource ?? "");
1347
- }
1348
- }
1349
- /**
1350
- * Get audit log entries.
1351
- */
1352
- getAuditLog(filter) {
1353
- let entries = [...this.auditLog];
1354
- if (filter?.event) entries = entries.filter((e) => e.event === filter.event);
1355
- if (filter?.userId) entries = entries.filter((e) => e.userId === filter.userId);
1356
- if (filter?.since) entries = entries.filter((e) => e.timestamp >= filter.since);
1357
- return entries;
1358
- }
1359
- /**
1360
- * Apply data masking to a record.
1361
- */
1362
- maskRecord(record, userRoles = []) {
1363
- if (!this.policy.dataMasking?.rules?.length) return record;
1364
- const masked = { ...record };
1365
- const maskChar = this.policy.dataMasking.maskChar ?? "*";
1366
- for (const rule of this.policy.dataMasking.rules) {
1367
- if (!(rule.field in masked) || masked[rule.field] == null) continue;
1368
- if (rule.exemptRoles?.some((role) => userRoles.includes(role))) continue;
1369
- const value = String(masked[rule.field]);
1370
- switch (rule.strategy) {
1371
- case "full":
1372
- masked[rule.field] = maskChar.repeat(value.length);
1373
- break;
1374
- case "partial": {
1375
- const visible = rule.visibleChars ?? 4;
1376
- if (value.length <= visible) {
1377
- masked[rule.field] = maskChar.repeat(value.length);
1378
- } else {
1379
- masked[rule.field] = value.slice(0, visible) + maskChar.repeat(value.length - visible);
1380
- }
1381
- break;
1382
- }
1383
- case "hash":
1384
- masked[rule.field] = `[HASHED:${simpleHash(value)}]`;
1385
- break;
1386
- case "redact":
1387
- masked[rule.field] = "[REDACTED]";
1388
- break;
1389
- }
1390
- }
1391
- return masked;
1392
- }
1393
- /**
1394
- * Update the security policy.
1395
- */
1396
- updatePolicy(policy) {
1397
- this.policy = { ...this.policy, ...policy };
1398
- }
1399
- /**
1400
- * Get current security policy.
1401
- */
1402
- getPolicy() {
1403
- return { ...this.policy };
1404
- }
1405
- };
1406
- function simpleHash(str) {
1407
- let hash = 0;
1408
- for (let i = 0; i < str.length; i++) {
1409
- const char = str.charCodeAt(i);
1410
- hash = (hash << 5) - hash + char;
1411
- hash = hash & hash;
1412
- }
1413
- return Math.abs(hash).toString(36);
1414
- }
1415
-
1416
- // src/studio.ts
1417
- function createDefaultCanvasConfig(overrides) {
1418
- return {
1419
- width: 1200,
1420
- height: 800,
1421
- background: "grid",
1422
- gridSize: 8,
1423
- snapToGrid: true,
1424
- zoom: {
1425
- min: 0.25,
1426
- max: 3,
1427
- step: 0.1,
1428
- current: 1
1429
- },
1430
- panOffset: { x: 0, y: 0 },
1431
- showMinimap: false,
1432
- minimapPosition: "bottom-right",
1433
- ...overrides
1434
- };
1435
- }
1436
- function snapToGrid(x, y, gridSize) {
1437
- return {
1438
- x: Math.round(x / gridSize) * gridSize,
1439
- y: Math.round(y / gridSize) * gridSize
1440
- };
1441
- }
1442
- function calculateAutoLayout(items, canvasWidth, padding = 40, gap = 40) {
1443
- const positions = [];
1444
- let currentX = padding;
1445
- let currentY = padding;
1446
- let rowMaxHeight = 0;
1447
- for (const item of items) {
1448
- if (currentX + item.width + padding > canvasWidth) {
1449
- currentX = padding;
1450
- currentY += rowMaxHeight + gap;
1451
- rowMaxHeight = 0;
1452
- }
1453
- positions.push({ id: item.id, x: currentX, y: currentY });
1454
- currentX += item.width + gap;
1455
- rowMaxHeight = Math.max(rowMaxHeight, item.height);
1456
- }
1457
- return positions;
1458
- }
1459
-
1460
1221
  // src/index.ts
1461
1222
  var FILTER_OPERATOR_ALIASES = {
1462
1223
  equals: "=",
@@ -1484,6 +1245,15 @@ var FILTER_OPERATOR_ALIASES = {
1484
1245
  notin: "nin",
1485
1246
  nin: "nin",
1486
1247
  contains: "contains",
1248
+ // Case-insensitive contains. A canonical `VIEW_FILTER_OPERATORS` member that
1249
+ // arrived with `@objectstack/spec` 17.1.0 (objectui#5328), and an IDENTITY row
1250
+ // like `contains` above: `icontains` is itself a member of
1251
+ // `VALID_AST_OPERATORS`, so the spelling the author writes is the spelling the
1252
+ // AST takes — nothing is translated and no case-sensitivity is lost.
1253
+ // Declared rather than left to the `?? op` fall-through on the rule this
1254
+ // table's test states: the AST gate accepting a spelling is not the driver
1255
+ // compiling it into a WHERE clause.
1256
+ icontains: "icontains",
1487
1257
  not_contains: "notcontains",
1488
1258
  notcontains: "notcontains",
1489
1259
  starts_with: "startswith",
@@ -1632,22 +1402,95 @@ function is404Error(error) {
1632
1402
  const code = typeof err.code === "string" ? err.code : "";
1633
1403
  return errorCodeIsAnyOf({ code }, ["OBJECT_NOT_FOUND", "RECORD_NOT_FOUND"]);
1634
1404
  }
1405
+ var API_ACCESS_DENIED_CODES = [
1406
+ "OBJECT_API_DISABLED",
1407
+ "OBJECT_API_METHOD_NOT_ALLOWED"
1408
+ ];
1409
+ function isApiAccessDeniedError(error) {
1410
+ return errorCodeIsAnyOf(error, API_ACCESS_DENIED_CODES);
1411
+ }
1412
+ var APP_PERMISSION_DENIED_CODE = "PERMISSION_DENIED";
1413
+ function isAppPermissionDeniedError(error) {
1414
+ return errorCodeIs(error, APP_PERMISSION_DENIED_CODE);
1415
+ }
1635
1416
  var AnalyticsNotInstalledError = class extends Error {
1636
- constructor(surface, detail) {
1417
+ constructor(surface, detail, serverCode) {
1637
1418
  super(
1638
1419
  `Analytics capability is not installed on this deployment \u2014 ${surface} is unavailable. Install @objectstack/service-analytics and mount AnalyticsServicePlugin to enable it.` + (detail ? ` (server said: ${detail})` : "")
1639
1420
  );
1640
1421
  __publicField(this, "code", "ANALYTICS_NOT_INSTALLED");
1641
1422
  /** The surface that was unavailable, for the message a host renders. */
1642
1423
  __publicField(this, "surface");
1424
+ /**
1425
+ * The server's own ADR-0112 code — the field this branch was chosen BY, when
1426
+ * a producer named one (`NOT_IMPLEMENTED` from the mounted route with no
1427
+ * service behind it, `ROUTE_NOT_FOUND` from the dispatcher when the route is
1428
+ * not mounted at all). Absent for the no-code residual, where a bare
1429
+ * transport 404/501 is all there was to read.
1430
+ *
1431
+ * Additive (objectui#5663): carried so a reader can audit that the headline
1432
+ * and the quoted `detail` below came off the same answer.
1433
+ */
1434
+ __publicField(this, "serverCode");
1643
1435
  this.name = "AnalyticsNotInstalledError";
1644
1436
  this.surface = surface;
1437
+ this.serverCode = serverCode;
1645
1438
  }
1646
1439
  };
1647
1440
  function isAnalyticsNotInstalledError(error) {
1648
1441
  if (!error || typeof error !== "object") return false;
1649
1442
  return error.code === "ANALYTICS_NOT_INSTALLED";
1650
1443
  }
1444
+ var AnalyticsDatasetNotFoundError = class extends Error {
1445
+ constructor(opts) {
1446
+ const subject = opts.datasetName ? `Dataset "${opts.datasetName}"` : "The requested dataset";
1447
+ super(
1448
+ `${subject} is not defined in this environment \u2014 the app that defines it is missing or out of date. Analytics itself is installed and answering, so the fix is to upgrade the installed app, not to install a server plugin.` + (opts.serverMessage ? ` (server said: ${opts.serverMessage})` : "")
1449
+ );
1450
+ __publicField(this, "code", "ANALYTICS_DATASET_NOT_FOUND");
1451
+ /**
1452
+ * The dataset this query asked for, taken from the REQUEST rather than parsed
1453
+ * back out of the server's prose: the request is what we know for certain,
1454
+ * and re-reading the name out of a message would make the headline depend on
1455
+ * that message's WORDING — the coupling framework#5367 spent a whole issue
1456
+ * removing from the producing route.
1457
+ */
1458
+ __publicField(this, "datasetName");
1459
+ /** The server's own ADR-0112 code — the field this branch was chosen BY. */
1460
+ __publicField(this, "serverCode");
1461
+ /** The server's own message, quoted verbatim in the parenthetical. */
1462
+ __publicField(this, "serverMessage");
1463
+ this.name = "AnalyticsDatasetNotFoundError";
1464
+ this.datasetName = opts.datasetName;
1465
+ this.serverCode = opts.serverCode;
1466
+ this.serverMessage = opts.serverMessage;
1467
+ }
1468
+ };
1469
+ var AnalyticsUnauthenticatedError = class extends Error {
1470
+ constructor(opts = {}) {
1471
+ super(
1472
+ "Analytics query refused: this session is not authenticated. Sign in again and retry \u2014 the request was refused before it ran, so it says nothing about whether the analytics capability is installed." + (opts.serverMessage ? ` (server said: ${opts.serverMessage})` : "")
1473
+ );
1474
+ __publicField(this, "code", "ANALYTICS_UNAUTHENTICATED");
1475
+ /** The server's own ADR-0112 code — the field this branch was chosen BY. */
1476
+ __publicField(this, "serverCode");
1477
+ /** The server's own message, quoted verbatim in the parenthetical. */
1478
+ __publicField(this, "serverMessage");
1479
+ this.name = "AnalyticsUnauthenticatedError";
1480
+ this.serverCode = opts.serverCode;
1481
+ this.serverMessage = opts.serverMessage;
1482
+ }
1483
+ };
1484
+ function readAnalyticsErrorEnvelope(body) {
1485
+ if (!body || typeof body !== "object") return {};
1486
+ const flat = body;
1487
+ const wrapped = flat.error && typeof flat.error === "object" ? flat.error : void 0;
1488
+ const source = wrapped ?? flat;
1489
+ const code = typeof source.code === "string" && source.code.length > 0 ? source.code : void 0;
1490
+ const message = typeof source.message === "string" && source.message.length > 0 ? source.message : typeof flat.error === "string" && flat.error.length > 0 ? flat.error : void 0;
1491
+ return { code, message };
1492
+ }
1493
+ var ANALYTICS_ABSENT_STATUSES = [404, 501];
1651
1494
  var AnalyticsQueryRejectedError = class extends Error {
1652
1495
  constructor(detail, serverCode) {
1653
1496
  super(
@@ -1662,13 +1505,22 @@ var AnalyticsQueryRejectedError = class extends Error {
1662
1505
  };
1663
1506
  function classifyAnalyticsFailure(error) {
1664
1507
  const err = error ?? {};
1665
- const code = typeof err.code === "string" ? err.code : void 0;
1508
+ const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
1666
1509
  const message = typeof err.message === "string" ? err.message : void 0;
1667
1510
  const status = typeof err.httpStatus === "number" ? err.httpStatus : typeof err.status === "number" ? err.status : typeof err.statusCode === "number" ? err.statusCode : void 0;
1668
- if (code === "VALIDATION_FAILED" || status === 400) return { kind: "rejected", code, message };
1669
- if (status === 404 || status === 501 || code === "NOT_IMPLEMENTED" || code === "ROUTE_NOT_FOUND") {
1511
+ if (errorCodeIsAnyOf({ code }, ["NOT_IMPLEMENTED", "ROUTE_NOT_FOUND"])) {
1670
1512
  return { kind: "not-installed", code, message };
1671
1513
  }
1514
+ if (errorCodeIs({ code }, "VALIDATION_FAILED")) return { kind: "rejected", code, message };
1515
+ if (errorCodeIs({ code }, "UNAUTHENTICATED")) return { kind: "unauthenticated", code, message };
1516
+ if (errorCodeIs({ code }, "CUBE_NOT_FOUND")) return { kind: "cube-not-found", code, message };
1517
+ if (code === void 0) {
1518
+ if (status !== void 0 && ANALYTICS_ABSENT_STATUSES.includes(status)) {
1519
+ return { kind: "not-installed", code, message };
1520
+ }
1521
+ if (status === 400) return { kind: "rejected", code, message };
1522
+ if (status === 401) return { kind: "unauthenticated", code, message };
1523
+ }
1672
1524
  return { kind: "unknown", code, message };
1673
1525
  }
1674
1526
  var ConcurrentUpdateError = class extends Error {
@@ -1716,6 +1568,19 @@ function normaliseClientError(error) {
1716
1568
  message: typeof e.message === "string" ? e.message : void 0
1717
1569
  });
1718
1570
  }
1571
+ function formatHttpFailureMessage(message, meta) {
1572
+ if (!meta || typeof meta !== "object") return null;
1573
+ const method = typeof meta.method === "string" && meta.method ? meta.method : void 0;
1574
+ const url = typeof meta.url === "string" && meta.url ? meta.url : void 0;
1575
+ const status = typeof meta.status === "number" ? meta.status : typeof meta.statusCode === "number" ? meta.statusCode : void 0;
1576
+ if (!method && !url && status === void 0) return null;
1577
+ const errBody = meta.error && typeof meta.error === "object" ? meta.error : void 0;
1578
+ const rawCode = typeof meta.code === "string" && meta.code || (errBody && typeof errBody.code === "string" ? errBody.code : void 0);
1579
+ const parts = [method ?? "GET", url ?? "<unknown url>"];
1580
+ parts.push(`-> ${status ?? "no status"}`);
1581
+ if (rawCode) parts.push(`[${rawCode}]`);
1582
+ return `${message}: ${parts.join(" ")}`;
1583
+ }
1719
1584
  function createQuietHttpLogger() {
1720
1585
  const isExpected404 = (meta) => {
1721
1586
  if (!meta || typeof meta !== "object") return false;
@@ -1728,17 +1593,27 @@ function createQuietHttpLogger() {
1728
1593
  return false;
1729
1594
  };
1730
1595
  const logger = {
1731
- debug: (message, meta) => console.debug(message, meta ?? ""),
1732
- info: (message, meta) => console.info(message, meta ?? ""),
1596
+ debug: (message, meta) => (
1597
+ // eslint-disable-next-line no-console -- objectui#4029, see comment above
1598
+ console.debug(message, meta ?? "")
1599
+ ),
1600
+ info: (message, meta) => (
1601
+ // eslint-disable-next-line no-console -- objectui#4029, see comment above
1602
+ console.info(message, meta ?? "")
1603
+ ),
1733
1604
  warn: (message, meta) => console.warn(message, meta ?? ""),
1734
1605
  error: (message, error, meta) => {
1735
1606
  if (isExpected404(meta)) {
1736
- console.debug(`[ObjectStack] ${message} (suppressed expected 404)`, meta);
1607
+ console.debug(
1608
+ `[ObjectStack] ${formatHttpFailureMessage(message, meta) ?? message} (suppressed expected 404)`,
1609
+ meta
1610
+ );
1737
1611
  return;
1738
1612
  }
1739
- console.error(message, error ?? "", meta ?? "");
1613
+ console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? "", meta ?? "");
1740
1614
  },
1741
- fatal: (message, error, meta) => console.error(message, error ?? "", meta ?? ""),
1615
+ fatal: (message, error, meta) => console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? "", meta ?? ""),
1616
+ // eslint-disable-next-line no-console -- objectui#4029, see comment above
1742
1617
  log: (message, ...args) => console.log(message, ...args),
1743
1618
  child: () => logger,
1744
1619
  withTrace: () => logger
@@ -1777,6 +1652,73 @@ function withoutNoOpDrops(droppedFields, sent, stored) {
1777
1652
  }
1778
1653
  return out;
1779
1654
  }
1655
+ function viewItemObjectName(item) {
1656
+ const spec = item?.list ?? item;
1657
+ return spec?.data?.object ?? spec?.object ?? spec?.objectName;
1658
+ }
1659
+ var VIEW_OVERLAY_MARKER = "_isOverride";
1660
+ function isLegacyOverlayRow(item, spec) {
1661
+ if (spec && spec.config && typeof spec.config === "object") return false;
1662
+ const viewKind = item?.viewKind ?? spec?.viewKind;
1663
+ return viewKind === "list";
1664
+ }
1665
+ function isPersonalizationOverlayRow(item, spec) {
1666
+ if (item?.[VIEW_OVERLAY_MARKER] === true) return true;
1667
+ if (spec?.[VIEW_OVERLAY_MARKER] === true) return true;
1668
+ return isLegacyOverlayRow(item, spec);
1669
+ }
1670
+ var VIEW_OVERLAY_OWNED_KEYS = Object.freeze([
1671
+ "rowHeight",
1672
+ "sort",
1673
+ "hiddenFields",
1674
+ "columnState",
1675
+ "inlineEdit"
1676
+ ]);
1677
+ var VIEW_OVERLAY_IDENTITY_KEYS = Object.freeze([
1678
+ "name",
1679
+ "object",
1680
+ "viewKind",
1681
+ VIEW_OVERLAY_MARKER
1682
+ ]);
1683
+ function narrowPersonalizationOverlay(row) {
1684
+ if (!row || typeof row !== "object" || Array.isArray(row)) return row;
1685
+ const item = row;
1686
+ const spec = item.list ?? item;
1687
+ if (!isPersonalizationOverlayRow(item, spec)) return row;
1688
+ const narrowed = {};
1689
+ for (const key of VIEW_OVERLAY_IDENTITY_KEYS) {
1690
+ if (item[key] !== void 0) narrowed[key] = item[key];
1691
+ }
1692
+ for (const key of VIEW_OVERLAY_OWNED_KEYS) {
1693
+ if (item[key] !== void 0) narrowed[key] = item[key];
1694
+ }
1695
+ return narrowed;
1696
+ }
1697
+ function unwrapViewDraft(resp) {
1698
+ if (!resp || typeof resp !== "object") return null;
1699
+ const env = resp;
1700
+ const body = "item" in env ? env.item : env;
1701
+ if (!body || typeof body !== "object") return null;
1702
+ const spec = body.list ?? body;
1703
+ if (!spec || typeof spec !== "object") return null;
1704
+ return Object.keys(spec).length > 0 ? spec : null;
1705
+ }
1706
+ function readViewDeleteReceipt(result) {
1707
+ const r = result ?? void 0;
1708
+ return {
1709
+ removed: !!(r?.deleted ?? r?.reset ?? true),
1710
+ ...typeof r?.reset === "boolean" ? { reset: r.reset } : {},
1711
+ ...typeof r?.message === "string" ? { message: r.message } : {}
1712
+ };
1713
+ }
1714
+ function mergeViewPatch(current, partial, viewName, objectName) {
1715
+ return {
1716
+ ...current,
1717
+ ...partial,
1718
+ name: viewName,
1719
+ object: current?.object || current?.data?.object || objectName
1720
+ };
1721
+ }
1780
1722
  var ObjectStackAdapter = class {
1781
1723
  constructor(config) {
1782
1724
  __publicField(this, "client");
@@ -1829,7 +1771,14 @@ var ObjectStackAdapter = class {
1829
1771
  // whose response carried `droppedFields` (framework #3431/#3455) so the app
1830
1772
  // shell can surface a toast instead of the strip passing silently.
1831
1773
  __publicField(this, "writeWarningListeners", /* @__PURE__ */ new Set());
1774
+ // Subscribers registered via onSaveAdvisory(). Emitted after a metadata save
1775
+ // through THIS adapter's `ObjectStackClient` whose 200 carried a non-empty
1776
+ // `advisories` array (#4237; backend objectstack#7435). Sibling of the set
1777
+ // above in every respect except which door produced the event: that one is
1778
+ // record CRUD, this one is the metadata save door.
1779
+ __publicField(this, "saveAdvisoryListeners", /* @__PURE__ */ new Set());
1832
1780
  this.client = new import_client.ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
1781
+ this.installSaveAdvisoryInterceptor();
1833
1782
  this.metadataCache = new MetadataCache(config.cache);
1834
1783
  this.autoReconnect = config.autoReconnect ?? true;
1835
1784
  this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3;
@@ -1984,7 +1933,7 @@ var ObjectStackAdapter = class {
1984
1933
  const result = await this.client.data.find(resource, queryOptions);
1985
1934
  return this.normalizeQueryResult(result, params);
1986
1935
  } catch (err) {
1987
- if (is404Error(err)) {
1936
+ if (!isApiAccessDeniedError(err) && is404Error(err)) {
1988
1937
  this.missingResources.add(resource);
1989
1938
  return { data: [], total: 0 };
1990
1939
  }
@@ -2212,6 +2161,115 @@ var ObjectStackAdapter = class {
2212
2161
  this.writeWarningListeners.delete(callback);
2213
2162
  };
2214
2163
  }
2164
+ /**
2165
+ * Subscribe to metadata save-advisory events — the runtime authoring gate's
2166
+ * advisory findings on a save that SUCCEEDED (#4237; backend
2167
+ * objectstack#7435). Returns an unsubscribe function.
2168
+ *
2169
+ * Deliberately the same seam as {@link onWriteWarning} (#3431/#3455), which
2170
+ * is what {@link MetadataSaveAdvisoryEvent}'s own declaration already said it
2171
+ * was modelled on. It is a SIBLING of that channel rather than a second
2172
+ * payload pushed down it: `WriteWarningEvent` is a closed shape whose
2173
+ * `droppedFields` is required and means "fields the write legally stripped",
2174
+ * so carrying advisories on it would either force every existing
2175
+ * `onWriteWarning` consumer to grow a branch or make the event lie about what
2176
+ * happened. The seam's SHAPE is what is reused here — a long-lived instance
2177
+ * with a `subscribe → unsubscribe` registration that `AdapterProvider` wires
2178
+ * once — not its event type.
2179
+ *
2180
+ * Why here and not on the config, which is how the other client class does it
2181
+ * (#4133/#4236): `MetadataClient` is minted per component by
2182
+ * `useMetadataClient`, so it has no instance to subscribe to and its sink
2183
+ * rides the factory. `ObjectStackAdapter` is the opposite — one long-lived
2184
+ * instance per app, already carrying this exact subscription pattern.
2185
+ */
2186
+ onSaveAdvisory(callback) {
2187
+ this.saveAdvisoryListeners.add(callback);
2188
+ return () => {
2189
+ this.saveAdvisoryListeners.delete(callback);
2190
+ };
2191
+ }
2192
+ /**
2193
+ * Notify all save-advisory subscribers. Isolated exactly like
2194
+ * {@link emitWriteWarning}: a throwing listener must neither break the save
2195
+ * nor starve the others.
2196
+ */
2197
+ emitSaveAdvisory(event) {
2198
+ for (const listener of this.saveAdvisoryListeners) {
2199
+ try {
2200
+ listener(event);
2201
+ } catch (err) {
2202
+ console.warn("ObjectStackAdapter: save-advisory listener error", err);
2203
+ }
2204
+ }
2205
+ }
2206
+ /**
2207
+ * Install the ONE emitter for the metadata save door (#4237).
2208
+ *
2209
+ * ## Why this seam, and what it covers
2210
+ *
2211
+ * `ObjectStackClient.meta.saveItem` is the second client class that writes
2212
+ * through `PUT /api/v1/meta/:type/:name`, and every one of its callers reaches
2213
+ * it through an adapter this class constructed — the four inside this file
2214
+ * (`updateViewConfig`, the two view paths, `updateDashboard`) via
2215
+ * `this.client`, and every caller outside it via {@link getClient}, which
2216
+ * hands back this same instance: `MetadataService` (app-shell, five saves),
2217
+ * `useNavigationSync`, and plugin-designer's Create/EditAppPage. Wrapping the
2218
+ * method once here therefore covers all of them WITHOUT a per-site edit, which
2219
+ * is the whole point — a toast copied into a dozen call sites is the shape
2220
+ * #4133 rejected for the other client class and it is no better here.
2221
+ *
2222
+ * `meta` is an own, writable property assigned per instance in the SDK's
2223
+ * constructor (`this.meta = { … }`), and the client this adapter builds is
2224
+ * never shared, so the wrap is bounded to an object this adapter owns for its
2225
+ * whole lifetime. It is not a prototype or global patch.
2226
+ *
2227
+ * ## Response shape — measured, not assumed
2228
+ *
2229
+ * The two client classes' envelopes coincide at the top level, which is what
2230
+ * makes `readSaveAdvisories` reusable unchanged across both. `SaveMetaItem-
2231
+ * ResponseSchema` puts `advisories` at the body's top level next to
2232
+ * `success` / `version` / `seq` / `state`, and the SDK's `unwrapResponse`
2233
+ * strips its `{ success, data }` envelope only when the body actually HAS a
2234
+ * `data` key — this body does not, so it is returned verbatim. So the same
2235
+ * reader that `MetadataClient.save` uses reads this response correctly, and
2236
+ * the pins in `onSaveAdvisory.test.ts` drive a real SDK client through a fake
2237
+ * `fetch` rather than stubbing `meta`, so that continues to be measured.
2238
+ *
2239
+ * ## Draft-door honesty (D1)
2240
+ *
2241
+ * Drafts are NEVER gated: the framework returns at its D1 early-return
2242
+ * (`if (args.state !== 'active') return null`) before running a rule, so a
2243
+ * draft save produces no findings to withhold. This client class has no draft
2244
+ * door at all to worry about — the SDK's `saveItem(type, name, item)` takes no
2245
+ * mode and always writes the active door, which is exactly why the gate DOES
2246
+ * run for its callers. `mode` on the emitted event is therefore derived from
2247
+ * the response's own `state` rather than from a request-side flag that does
2248
+ * not exist here: `'draft'` when the server says the row landed as a draft,
2249
+ * `'publish'` otherwise. That keeps the event truthful about which door it
2250
+ * came through instead of hard-coding one.
2251
+ */
2252
+ installSaveAdvisoryInterceptor() {
2253
+ const meta = this.client.meta;
2254
+ const original = meta.saveItem.bind(meta);
2255
+ meta.saveItem = async (type, name, item) => {
2256
+ const result = await original(type, name, item);
2257
+ try {
2258
+ const advisories = readSaveAdvisories(result);
2259
+ if (advisories.length > 0) {
2260
+ this.emitSaveAdvisory({
2261
+ type,
2262
+ name,
2263
+ mode: result?.state === "draft" ? "draft" : "publish",
2264
+ advisories
2265
+ });
2266
+ }
2267
+ } catch (err) {
2268
+ console.warn("ObjectStackAdapter: save-advisory read error", err);
2269
+ }
2270
+ return result;
2271
+ };
2272
+ }
2215
2273
  async create(resource, data) {
2216
2274
  await this.connect();
2217
2275
  try {
@@ -2271,10 +2329,10 @@ var ObjectStackAdapter = class {
2271
2329
  String(id),
2272
2330
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
2273
2331
  );
2274
- if (result.deleted) {
2332
+ if (result.success) {
2275
2333
  this.emitMutation({ type: "delete", resource, id });
2276
2334
  }
2277
- return result.deleted;
2335
+ return result.success;
2278
2336
  } catch (err) {
2279
2337
  throw normaliseClientError(err);
2280
2338
  }
@@ -2817,6 +2875,8 @@ var ObjectStackAdapter = class {
2817
2875
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
2818
2876
  const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
2819
2877
  err.status = res.status;
2878
+ err.code = errorBody?.code ?? errorBody?.error?.code;
2879
+ err.httpStatus = res.status;
2820
2880
  throw err;
2821
2881
  }
2822
2882
  const body = await res.json();
@@ -3075,42 +3135,109 @@ var ObjectStackAdapter = class {
3075
3135
  return null;
3076
3136
  }
3077
3137
  }
3138
+ /**
3139
+ * **The one place that knows which cache keys a view-row write invalidates.**
3140
+ *
3141
+ * Two reads are cached under view-shaped keys, and both go stale when any
3142
+ * writer touches a view row for `objectName`:
3143
+ *
3144
+ * | reader | key |
3145
+ * |----------------------------|----------------------------|
3146
+ * | {@link getView} | `view:{object}:{viewName}` |
3147
+ * | {@link listViewOverrides} | `view-overrides:{object}` |
3148
+ *
3149
+ * Every writer routes here rather than restating that pair. The repo has now
3150
+ * paid twice for restatement: objectui#3778 removed five copies of a key
3151
+ * (`views:{object}`) that no reader had ever populated, and objectui#4363
3152
+ * fixed four copies that named only half the live set — and objectui#4373 is
3153
+ * the measured proof that the failure recurs by default, because the console's
3154
+ * real create/publish flow writes these same rows through the ADR-0034
3155
+ * metadata seam and never learned the key list at all. A pin suite can only
3156
+ * guard the writers that exist; a mandatory seam makes the next writer
3157
+ * structurally unable to forget.
3158
+ *
3159
+ * The rule is uniform per WRITE, not per branch: a write to a view row drops
3160
+ * both keys, in that order. Draft-addressed writes over-invalidate on purpose
3161
+ * — both readers enumerate PUBLISHED rows, so a draft write stales neither —
3162
+ * because the costs are not symmetric. An unnecessary invalidation costs one
3163
+ * refetch; a missed one costs up to the cache's 5-minute TTL of stale
3164
+ * overrides, and "which half am I in?" is not a question a future writer
3165
+ * should have to re-answer to stay correct.
3166
+ *
3167
+ * Public because the writers are not all in this class: app-shell's ADR-0034
3168
+ * seam (`runtime-metadata-persistence.ts`) and `MetadataService` write the
3169
+ * same rows through `client.meta` / the `/meta` draft-publish API, and they
3170
+ * reach this adapter as the `dataSource` they were already handed.
3171
+ *
3172
+ * @param objectName - Object the view belongs to (the `{object}` in both keys)
3173
+ * @param viewName - The view's canonical `name` — the identity
3174
+ * {@link getView} reads back by and {@link listViewOverrides} keys its map
3175
+ * with. Callers that hold a qualified `<object>.<key>` name pass it as-is:
3176
+ * it is the row key, not a path to be split.
3177
+ */
3178
+ invalidateViewKeys(objectName, viewName) {
3179
+ this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
3180
+ this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
3181
+ }
3078
3182
  /**
3079
3183
  * Batch-fetch all persisted view overrides for an object.
3080
3184
  *
3081
- * Per-view runtime overrides (density, column widths, sort, …) are
3082
- * stored in the metadata registry under key `<objectName>/<viewName>`.
3083
- * Loading them per-view fires N HTTP GETs that return 404 for views
3084
- * the user has never customized — generates console noise on every
3085
- * page load. This batch method performs a single
3086
- * `GET /api/v1/meta/<objectName>` (returns `{type, items}`) and
3087
- * returns a `{viewName: override}` map. Returns an empty map if the
3088
- * server doesn't expose the listing or returns no items.
3185
+ * Per-view runtime overrides (density, column widths, sort, hidden
3186
+ * columns, inlineEdit …) live in the SAME metadata namespace the
3187
+ * write path uses: `type='view'`, `name=<viewId>` (see
3188
+ * {@link updateViewConfig}). Loading them per-view fires N HTTP GETs
3189
+ * that 404 for every view the user never customized — console noise on
3190
+ * every page load. This batch method performs a single
3191
+ * `GET /api/v1/meta/view` (returns `{type, items}`) and narrows the
3192
+ * result to `objectName` client-side, exactly as {@link listViews}
3193
+ * does over the same rows (shared accessor: {@link viewItemObjectName}).
3089
3194
  *
3090
- * Result is cached identically to {@link getView}; saving a view via
3091
- * {@link updateViewConfig} invalidates the cache.
3195
+ * objectui#3774 — this used to enumerate `GET /api/v1/meta/<objectName>`,
3196
+ * putting the OBJECT name in the metadata TYPE slot. That key space is
3197
+ * disjoint from the one the write path lands in, so the batch map came
3198
+ * back empty for every object, forever, and every saved personalization
3199
+ * read back as "setting didn't save".
3200
+ *
3201
+ * FAILURES REJECT — they are not answered as `{}`. An empty map is an
3202
+ * authoritative "this object has no overrides" (callers may trust it and
3203
+ * skip the per-view reads); a transport/permission failure is "I could
3204
+ * not tell", and reporting the two as the same value is what made
3205
+ * ObjectView's per-view {@link getView} fallback unreachable code. When
3206
+ * we cannot tell, we do not pretend we can. Rejections are not cached
3207
+ * (the cache stores on success only), so a transient failure does not
3208
+ * pin an empty answer for the TTL.
3209
+ *
3210
+ * Result is cached identically to {@link getView}, and EVERY view write —
3211
+ * on this adapter and above it — invalidates it through the one seam,
3212
+ * {@link invalidateViewKeys}. For a long time only {@link updateViewConfig}
3213
+ * did (objectui#4363), which left the other three adapter paths stale
3214
+ * for the cache's 5-minute TTL. That gap does not self-heal: the consumer
3215
+ * (`loadViewOverrides`, app-shell `ObjectView`) treats a RESOLVED map as
3216
+ * authoritative and deliberately does not re-probe per view — objectui#3774,
3217
+ * and correct, since re-probing reinstates the 404 flurry the batch read
3218
+ * exists to remove. So a stale map here is served in full, and the per-view
3219
+ * {@link getView} fallback that would have masked it never runs.
3092
3220
  *
3093
3221
  * @param objectName - Object name (e.g. 'lead')
3094
3222
  * @returns Map keyed by view name with the persisted override config
3223
+ * @throws whatever the metadata transport throws — callers that have a
3224
+ * per-view fallback should catch and use it.
3095
3225
  */
3096
3226
  async listViewOverrides(objectName) {
3097
3227
  await this.connect();
3098
- try {
3099
- const cacheKey = `view-overrides:${objectName}`;
3100
- return await this.metadataCache.get(cacheKey, async () => {
3101
- const result = await this.client.meta.getItems(objectName);
3102
- const items = Array.isArray(result?.items) ? result.items : [];
3103
- const out = {};
3104
- for (const it of items) {
3105
- if (!it || typeof it !== "object") continue;
3106
- const key = it.name ?? it.id ?? it._name;
3107
- if (typeof key === "string" && key) out[key] = it;
3108
- }
3109
- return out;
3110
- });
3111
- } catch {
3112
- return {};
3113
- }
3228
+ const cacheKey = `view-overrides:${objectName}`;
3229
+ return await this.metadataCache.get(cacheKey, async () => {
3230
+ const result = await this.client.meta.getItems("view");
3231
+ const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
3232
+ const out = {};
3233
+ for (const it of items) {
3234
+ if (!it || typeof it !== "object") continue;
3235
+ if (viewItemObjectName(it) !== objectName) continue;
3236
+ const key = it.name;
3237
+ if (typeof key === "string" && key) out[key] = it;
3238
+ }
3239
+ return out;
3240
+ });
3114
3241
  }
3115
3242
  /**
3116
3243
  * Get a view definition for an object.
@@ -3136,34 +3263,65 @@ var ObjectStackAdapter = class {
3136
3263
  }
3137
3264
  }
3138
3265
  /**
3139
- * Persist a view definition for an object.
3266
+ * Persist a toolbar-driven view config patch — density, column widths,
3267
+ * sort, hidden columns, inline edit. Symmetric counterpart to
3268
+ * {@link getView}: writes the row to the server metadata store via
3269
+ * `client.meta.saveItem`, then invalidates the matching cache entry so the
3270
+ * next {@link getView} reflects the new payload. Returns the persisted item
3271
+ * when the server echoes it, otherwise undefined.
3272
+ *
3273
+ * Called from exactly ONE production site — `ObjectView`'s
3274
+ * `persistViewPatch`, for the toolbar toggle — but that ONE call site
3275
+ * fires for BOTH kinds of active tab: a code-defined **system** view (no
3276
+ * row of its own yet) and a genuinely user-created **saved** view (already
3277
+ * has a row — the toggle is editing ITS OWN definition, not laying an
3278
+ * overlay on top of it). Which one a given call means is NOT re-derived
3279
+ * here from the write's shape (objectui#4227's own lesson: shape inference
3280
+ * on this namespace is exactly what let a system view masquerade as
3281
+ * saved) — the caller already knows, via the same `isSavedViewId`
3282
+ * classification that gates the switcher's readonly flag and its five
3283
+ * mutating handlers, and passes it as {@link opts.isSavedView}.
3140
3284
  *
3141
- * Symmetric counterpart to {@link getView}: writes the view to the
3142
- * server metadata store via `client.meta.saveItem`, then invalidates
3143
- * the matching cache entry so the next {@link getView} reflects the
3144
- * new payload. Returns the persisted item when the server echoes it,
3145
- * otherwise undefined.
3285
+ * - `isSavedView` false/omitted (system-view target, the common case and
3286
+ * the default for backward compatibility): stamps
3287
+ * {@link VIEW_OVERLAY_MARKER} so {@link listViews} excludes the row —
3288
+ * the original objectui#4227 fix.
3289
+ * - `isSavedView` true: the marker is withheld. Stamping it here would
3290
+ * flag the saved view's OWN row as a personalization overlay, and
3291
+ * `listViews()` would exclude it on the very next read — the user's own
3292
+ * view would vanish from the switcher the moment they toggled its
3293
+ * density (objectui#4227 follow-up, PM review on PR #4713, measured:
3294
+ * `persistViewPatch` has no gate on which kind of tab is active, and
3295
+ * this method writes to the exact same `(type='view', name=viewId)` key
3296
+ * {@link createView}/the ADR-0034 `viewEnvelope` seam already used for
3297
+ * that view, so the write is an upsert onto the saved view's row, not a
3298
+ * new one).
3146
3299
  *
3147
- * Used by ObjectView for "live" toolbar persistence (density,
3148
- * column widths, sort, etc.) and by the View Config Panel for
3149
- * explicit saves.
3300
+ * Per objectstack#7494's ruling, the overlay this writes (system-view
3301
+ * case) is ORG-WIDE shared view settings, not a per-user preference — a
3302
+ * true per-user scope is a parked v18 direction on the platform side.
3150
3303
  *
3151
3304
  * @param objectName - Object name (e.g. 'lead')
3152
3305
  * @param viewId - View identifier (e.g. 'all_leads')
3153
3306
  * @param config - Full view definition to persist
3307
+ * @param opts.isSavedView - Whether `viewId` already names a saved view
3308
+ * (vs. a system view being personalized for the first time). Omit /
3309
+ * `false` for the default overlay-marking behavior.
3154
3310
  */
3155
- async updateViewConfig(objectName, viewId, config) {
3311
+ async updateViewConfig(objectName, viewId, config, opts) {
3156
3312
  await this.connect();
3157
- const merged = { ...config || {}, object: config?.object || objectName, name: viewId };
3313
+ const merged = {
3314
+ ...config || {},
3315
+ object: config?.object || objectName,
3316
+ name: viewId,
3317
+ ...opts?.isSavedView ? {} : { [VIEW_OVERLAY_MARKER]: true }
3318
+ };
3158
3319
  const result = await this.client.meta.saveItem(
3159
3320
  "view",
3160
3321
  viewId,
3161
3322
  merged
3162
3323
  );
3163
- const cacheKey = `view:${objectName}:${viewId}`;
3164
- this.metadataCache.invalidate?.(cacheKey);
3165
- this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
3166
- this.metadataCache.invalidate?.(`views:${objectName}`);
3324
+ this.invalidateViewKeys(objectName, viewId);
3167
3325
  if (result && result.item) return result.item;
3168
3326
  return result ?? void 0;
3169
3327
  }
@@ -3174,8 +3332,10 @@ var ObjectStackAdapter = class {
3174
3332
  * match the view spec shape.
3175
3333
  *
3176
3334
  * Returns view spec objects with their canonical `name` as identifier.
3177
- * Filters by `data.object === objectName` (or top-level `object`)
3178
- * client-side because the metadata index is name-only, not field-typed.
3335
+ * Narrows to one object client-side via {@link viewItemObjectName} —
3336
+ * the metadata index is name-only, not field-typed, so the route has no
3337
+ * `?object=` to push the filter down into. {@link listViewOverrides}
3338
+ * reads the same rows through the same accessor.
3179
3339
  */
3180
3340
  async listViews(objectName, options) {
3181
3341
  await this.connect();
@@ -3192,10 +3352,11 @@ var ObjectStackAdapter = class {
3192
3352
  return items.filter((v) => {
3193
3353
  if (!v) return false;
3194
3354
  const spec = v.list ?? v;
3195
- const obj = spec?.data?.object ?? spec?.object ?? spec?.objectName;
3196
- if (obj !== objectName) return false;
3355
+ if (viewItemObjectName(v) !== objectName) return false;
3197
3356
  const viewKind = v.viewKind ?? spec?.viewKind;
3198
- return !(viewKind && FORM_FAMILY.has(viewKind));
3357
+ if (viewKind && FORM_FAMILY.has(viewKind)) return false;
3358
+ if (isPersonalizationOverlayRow(v, spec)) return false;
3359
+ return true;
3199
3360
  }).map((v) => {
3200
3361
  const spec = v.list ?? v;
3201
3362
  const isDraft = v._draft === true || spec?._draft === true;
@@ -3254,6 +3415,10 @@ var ObjectStackAdapter = class {
3254
3415
  *
3255
3416
  * Generates a snake_case name if `spec.name` is not provided by appending
3256
3417
  * a short timestamp suffix to the source-name hint.
3418
+ *
3419
+ * Invalidates through {@link invalidateViewKeys}, like every other writer —
3420
+ * see {@link listViewOverrides} for why the batch map is the one that cannot
3421
+ * heal itself (objectui#4363).
3257
3422
  */
3258
3423
  async createView(objectName, spec) {
3259
3424
  await this.connect();
@@ -3273,48 +3438,184 @@ var ObjectStackAdapter = class {
3273
3438
  data: spec?.data || { provider: "object", object: objectName }
3274
3439
  };
3275
3440
  const result = await this.client.meta.saveItem("view", name, fullSpec);
3276
- this.metadataCache.invalidate?.(`views:${objectName}`);
3441
+ this.invalidateViewKeys(objectName, name);
3277
3442
  if (result && result.item) return result.item;
3278
3443
  return fullSpec;
3279
3444
  }
3280
3445
  /**
3281
3446
  * Apply a partial update to an existing overlay view. Reads the current
3282
- * overlay (or seeds from artifact), merges, and writes back. ADR-0005
3283
- * overlay rows store the *full* view document, so partial updates require
3284
- * a read-merge-write cycle.
3447
+ * document, merges, and writes it back. ADR-0005 overlay rows store the
3448
+ * *full* view document, so partial updates require a read-merge-write cycle.
3449
+ *
3450
+ * **Both halves address the same row (#4139).** A view has two possible
3451
+ * homes and the read must resolve the one the write will target:
3452
+ *
3453
+ * - a pending per-item **draft** (`?state=draft` / `?mode=draft`) — where
3454
+ * ADR-0034 stages every runtime-created view, so a view made from the `+`
3455
+ * tab lives ONLY here until an explicit Publish;
3456
+ * - the **published** overlay (`client.meta.getItem` / `saveItem`).
3457
+ *
3458
+ * The draft is probed FIRST, and a hit is merged and written straight back
3459
+ * as a draft. Two things that ordering buys, both load-bearing:
3460
+ *
3461
+ * 1. A draft-only view is no longer invisible to the read. It used to 404,
3462
+ * and a `catch {}` labelled "treat missing as create-equivalent"
3463
+ * substituted `current = {}` — so a rename merged onto nothing and went
3464
+ * out as a `{label, name, object}` partial the server rejects (422),
3465
+ * while the draft row the UI reads back through `?preview=draft` kept the
3466
+ * old label. The edit was lost with no error surfaced to the user.
3467
+ * 2. A draft is never bypassed. Writing the published row while a draft is
3468
+ * pending would put the edit somewhere the draft shadows — and Publish
3469
+ * would then overwrite it with the pre-edit body, losing the change a
3470
+ * second time, later, where nothing connects it to this call.
3471
+ *
3472
+ * A draft edit stays a draft: `mode: 'draft'` keeps ADR-0037's guarantee
3473
+ * that nothing the preview shows goes live until Publish. Renaming a
3474
+ * *published* view (no draft pending) is unchanged — it writes the
3475
+ * published overlay, as before.
3476
+ *
3477
+ * **Both halves invalidate through {@link invalidateViewKeys}** (objectui#4363,
3478
+ * #4373) — same call, same key set, so the draft half's deliberate
3479
+ * over-invalidation is the seam's uniform rule rather than a per-branch
3480
+ * decision this method has to keep re-making.
3481
+ *
3482
+ * @throws when the view resolves in neither home, or when either read fails
3483
+ * for any other reason (network, permission). Both used to be swallowed
3484
+ * and converted into the bad partial write above; a caller that wants a
3485
+ * view created should call {@link createView}, which is the operation that
3486
+ * actually means "create".
3285
3487
  */
3286
3488
  async updateView(objectName, viewName, partial) {
3287
3489
  await this.connect();
3288
- let current = {};
3490
+ const metaClient = this.metadataClient();
3491
+ const draft = unwrapViewDraft(
3492
+ await metaClient.get("view", viewName, { state: "draft" })
3493
+ );
3494
+ if (draft) {
3495
+ const mergedDraft = mergeViewPatch(draft, partial, viewName, objectName);
3496
+ await metaClient.save("view", viewName, mergedDraft, { mode: "draft" });
3497
+ this.invalidateViewKeys(objectName, viewName);
3498
+ return mergedDraft;
3499
+ }
3500
+ let current;
3289
3501
  try {
3290
3502
  const r = await this.client.meta.getItem("view", viewName);
3291
3503
  current = r && (r.item || r) || {};
3292
3504
  if (current?.list) current = current.list;
3293
- } catch {
3505
+ } catch (err) {
3506
+ if (is404Error(err)) {
3507
+ throw Object.assign(
3508
+ new Error(
3509
+ `updateView: view "${viewName}" not found on object "${objectName}" \u2014 no pending draft and no published overlay. Use createView() to create one.`
3510
+ ),
3511
+ { cause: err }
3512
+ );
3513
+ }
3514
+ throw err;
3294
3515
  }
3295
- const merged = {
3296
- ...current,
3297
- ...partial,
3298
- name: viewName,
3299
- object: current?.object || current?.data?.object || objectName
3300
- };
3516
+ const merged = mergeViewPatch(current, partial, viewName, objectName);
3301
3517
  const result = await this.client.meta.saveItem("view", viewName, merged);
3302
- this.metadataCache.invalidate?.(`views:${objectName}`);
3303
- this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
3518
+ this.invalidateViewKeys(objectName, viewName);
3304
3519
  if (result && result.item) return result.item;
3305
3520
  return merged;
3306
3521
  }
3307
3522
  /**
3308
- * Delete an overlay view (reset to artifact default if one exists, or
3309
- * remove entirely if it was a user-created view). Routes to
3310
- * `DELETE /api/v1/meta/view/:name`.
3523
+ * Delete an overlay view — from **every home it has**.
3524
+ *
3525
+ * A view has two possible homes, the same two {@link updateView} addresses:
3526
+ * the pending per-item **draft** (`DELETE /meta/view/:name?state=draft`) and
3527
+ * the **published** overlay (`DELETE /meta/view/:name`). This method used to
3528
+ * issue only the second, and the three cases came out like this (#4479):
3529
+ *
3530
+ * | case | before |
3531
+ * |--------------------|-----------------------------------------------------|
3532
+ * | draft-only view | BUG — the published delete answered `reset:false` / |
3533
+ * | | "nothing to delete", the draft survived, and the tab |
3534
+ * | | was still there after reload |
3535
+ * | published-only | correct — `reset:true`, tab gone |
3536
+ * | published + draft | ACCIDENTALLY correct — the published row went, so |
3537
+ * | | the tab went; the orphan draft stayed behind |
3538
+ *
3539
+ * **Why this is not the mechanical mirror of #4139.** `updateView` probes
3540
+ * the draft first and writes back to whichever home the read resolved, and
3541
+ * that is right for an update in all three cases. Copying it here would be
3542
+ * wrong in the third: `persistRuntimeMetadata` (app-shell) stages EVERY
3543
+ * runtime edit as a draft, so "publish a view, then edit it" routinely
3544
+ * produces a pair — and a draft-first-only delete on a pair discards the
3545
+ * draft and leaves the published row serving the view. That is not Delete
3546
+ * view, it is **Discard draft**, a deliberately different operation that
3547
+ * already exists (`discardRuntimeDraft`, documented as "the published
3548
+ * overlay is untouched"). The clean statement of the asymmetry: for an
3549
+ * update, one home is the right home; for a delete, "remove this view" is
3550
+ * satisfied only when NO home is left serving it.
3551
+ *
3552
+ * So both homes are deleted, **draft first**. The order is load-bearing on
3553
+ * the failure path: a fault between the two calls leaves the PUBLISHED
3554
+ * overlay intact, so the view is still served and the operation is cleanly
3555
+ * retryable. The reverse order would strand a draft-only view — which is
3556
+ * precisely the bug shape above.
3557
+ *
3558
+ * **Two blind calls, no probe.** The framework's `deleteMetaItem` answers a
3559
+ * missing home with a **200** carrying `reset:false` (`"No pending draft
3560
+ * for view/x."` / `"No view 'x' found — nothing to delete."`), never a 404,
3561
+ * so there is nothing for a probe to protect against. `updateView` needs its
3562
+ * probe for a different reason — its read must resolve the row the merge
3563
+ * writes back to — and that reason has no counterpart for a delete.
3564
+ *
3565
+ * **One transport, one error contract.** Both halves go through
3566
+ * {@link MetadataClient} (`reset`), the transport that can express the
3567
+ * `?state=` qualifier and the one `updateView`'s draft half already uses.
3568
+ * The published half used to go through `client.meta.deleteItem`; measured,
3569
+ * that issues the byte-identical request (`DELETE
3570
+ * {baseUrl}/api/v1/meta/view/:name`, no environment scoping is configured on
3571
+ * this adapter), so routing it here costs no addressing change and buys a
3572
+ * single `MetadataError` shape across both calls instead of two.
3573
+ *
3574
+ * @returns `deleted` is true only when no home is left serving the view AND
3575
+ * at least one actually held a row. A view that existed in neither home
3576
+ * still answers `false`, unchanged. The per-home outcomes are additive:
3577
+ * a partial result is observable rather than rounded up to `true`.
3578
+ * @throws when either delete fails, matching {@link updateView}'s
3579
+ * convention of surfacing the fault rather than degrading. A failure of
3580
+ * the PUBLISHED half after the draft was discarded carries the partial
3581
+ * state on the error's `outcome` — "draft gone, overlay left" is exactly
3582
+ * what the old `{ deleted: boolean }` could not express.
3583
+ *
3584
+ * Invalidates through {@link invalidateViewKeys}: the deleted row leaves the
3585
+ * batch override map too, and a ghost entry there is what the object page
3586
+ * would keep applying (objectui#4363). Fired in a `finally`, so it happens
3587
+ * once per call on EVERY outcome including the throw — after a half-failure
3588
+ * the draft row really is gone, and #4363's asymmetry decides it: an
3589
+ * unnecessary invalidation costs one refetch, a missed one costs the cache's
3590
+ * full 5-minute TTL of stale overrides.
3311
3591
  */
3312
3592
  async deleteView(objectName, viewName) {
3313
3593
  await this.connect();
3314
- const result = await this.client.meta.deleteItem("view", viewName);
3315
- this.metadataCache.invalidate?.(`views:${objectName}`);
3316
- this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
3317
- return { deleted: !!(result?.deleted ?? result?.reset ?? true) };
3594
+ const metaClient = this.metadataClient();
3595
+ try {
3596
+ const draft = readViewDeleteReceipt(
3597
+ await metaClient.reset("view", viewName, { state: "draft" })
3598
+ );
3599
+ let published;
3600
+ try {
3601
+ published = readViewDeleteReceipt(await metaClient.reset("view", viewName));
3602
+ } catch (err) {
3603
+ const outcome = {
3604
+ deleted: false,
3605
+ draft,
3606
+ published: { removed: false }
3607
+ };
3608
+ throw Object.assign(
3609
+ new Error(
3610
+ `deleteView: view "${viewName}" on object "${objectName}" is NOT fully removed \u2014 the draft home was ${draft.removed ? "discarded" : "already absent"}, but deleting the published overlay failed. The published row is still serving the view; retry the delete.`
3611
+ ),
3612
+ { cause: err, outcome }
3613
+ );
3614
+ }
3615
+ return { deleted: draft.removed || published.removed, draft, published };
3616
+ } finally {
3617
+ this.invalidateViewKeys(objectName, viewName);
3618
+ }
3318
3619
  }
3319
3620
  /**
3320
3621
  * Get an application definition by name or ID.
@@ -3330,7 +3631,7 @@ var ObjectStackAdapter = class {
3330
3631
  try {
3331
3632
  const cacheKey = `app:${appId}`;
3332
3633
  return await this.metadataCache.get(cacheKey, async () => {
3333
- const result = await this.client.meta.getItem("apps", appId);
3634
+ const result = await this.client.meta.getItem("app", appId);
3334
3635
  if (result && result.item) return result.item;
3335
3636
  return result ?? null;
3336
3637
  });
@@ -3338,6 +3639,56 @@ var ObjectStackAdapter = class {
3338
3639
  return null;
3339
3640
  }
3340
3641
  }
3642
+ /**
3643
+ * Ask the by-name meta app route WHY an app is not in this session's app list
3644
+ * (objectui#4252).
3645
+ *
3646
+ * The app LIST is the generic metadata list route `GET /api/v1/meta/:type`,
3647
+ * requested with the singular type segment `app` — the same address this
3648
+ * method appends a name to below, and the one `MetadataProvider` reads its
3649
+ * items from. The server filters that list per session in `filterAppForUser`
3650
+ * (`packages/rest/src/rest-server.ts`, applied inside the `:type` list handler
3651
+ * once the type segment resolves to `app`), so an app withheld by
3652
+ * `requiredPermissions` and an app
3653
+ * that does not exist are byte-identical there: both are simply absent. A
3654
+ * console reading only that list has one fact and two conditions, and it
3655
+ * renders its copy for the wrong one — "it may still be publishing" over a
3656
+ * permanent authorization decision, which cost a downstream acceptance round
3657
+ * two test batches spent chasing a platform defect that was a missing
3658
+ * permission-set binding.
3659
+ *
3660
+ * The maintainer ruling (2026-08-12) put the answer on the BY-NAME route
3661
+ * rather than in the list, so the enumeration surface is not widened past what
3662
+ * a by-name probe already implies (objectstack#8013 / PR #8135): an app that
3663
+ * exists and whose `requiredPermissions` the session lacks answers `403` with
3664
+ * `PERMISSION_DENIED` in the declared envelope, and absence — a nonexistent
3665
+ * name, an unpublished app, an app gated by an absent optional service —
3666
+ * keeps answering `404 RESOURCE_NOT_FOUND`.
3667
+ *
3668
+ * ## Why this is a separate method and not a flavour of {@link getApp}
3669
+ *
3670
+ * - `getApp` degrades EVERY failure to `null`, which is exactly the
3671
+ * conflation this exists to undo; changing it would silently re-point its
3672
+ * own callers' fallback-to-static-config path.
3673
+ * - `getApp` memoises in `metadataCache`. A verdict about the CALLER must not
3674
+ * be cached beside a document about the APP — one grant, and a cached
3675
+ * denial outlives the session it described.
3676
+ *
3677
+ * Nothing here throws: a probe that cannot reach an answer returns `unknown`
3678
+ * and the caller keeps whatever it was already showing. Only the measured
3679
+ * `code` produces `denied` — never a status, never a message (objectui#4408).
3680
+ *
3681
+ * @param appName - the app name as it appears in the URL segment
3682
+ */
3683
+ async probeAppAccess(appName) {
3684
+ if (!appName) return "unknown";
3685
+ try {
3686
+ await this.client.meta.getItem("app", appName);
3687
+ return "granted";
3688
+ } catch (err) {
3689
+ return isAppPermissionDeniedError(err) ? "denied" : "unknown";
3690
+ }
3691
+ }
3341
3692
  /**
3342
3693
  * Get a page definition from ObjectStack.
3343
3694
  * Uses the metadata API to fetch page layouts.
@@ -3348,7 +3699,7 @@ var ObjectStackAdapter = class {
3348
3699
  try {
3349
3700
  const cacheKey = `page:${pageId}`;
3350
3701
  return await this.metadataCache.get(cacheKey, async () => {
3351
- const result = await this.client.meta.getItem("pages", pageId);
3702
+ const result = await this.client.meta.getItem("page", pageId);
3352
3703
  if (result && result.item) return result.item;
3353
3704
  return result ?? null;
3354
3705
  });
@@ -3438,6 +3789,15 @@ var ObjectStackAdapter = class {
3438
3789
  if (failure.kind === "rejected") {
3439
3790
  throw new AnalyticsQueryRejectedError(failure.message, failure.code);
3440
3791
  }
3792
+ if (failure.kind === "unauthenticated") {
3793
+ throw new AnalyticsUnauthenticatedError({
3794
+ serverCode: failure.code,
3795
+ serverMessage: failure.message
3796
+ });
3797
+ }
3798
+ if (failure.kind === "cube-not-found") {
3799
+ throw e;
3800
+ }
3441
3801
  if (failure.kind === "not-installed") {
3442
3802
  this.warnAnalyticsCapabilityOnce(failure.message);
3443
3803
  }
@@ -3490,7 +3850,17 @@ var ObjectStackAdapter = class {
3490
3850
  * silently returning wrong numbers.
3491
3851
  *
3492
3852
  * @param dataset - An inline dataset definition (draft) OR a saved dataset name.
3493
- * @param selection - Dimension/measure names to project + runtime directives.
3853
+ * @param selection - The spec's {@link DatasetSelection} — dimension/measure
3854
+ * names to project plus runtime directives. This parameter IS the spec type
3855
+ * by reference, never a local restatement of it (objectui#3613): a hand
3856
+ * copy of a contract is a second dialect of it, and the copy this replaced
3857
+ * had already drifted three ways from `@objectstack/spec` — it required
3858
+ * `compareTo.dimension` (optional since objectstack#5011, and resolved by
3859
+ * the EXECUTOR, so requiring it pushed callers into exactly the
3860
+ * consumer-side dimension guess AGENTS.md #0.1 forbids), it widened
3861
+ * `timeDimensions` to `unknown[]` and `runtimeFilter` to
3862
+ * `Record<string, unknown>`, and it had never grown `dateGranularity` at
3863
+ * all. Pinned in `queryDataset.test.ts`.
3494
3864
  */
3495
3865
  async queryDataset(dataset, selection) {
3496
3866
  await this.connect();
@@ -3507,14 +3877,35 @@ var ObjectStackAdapter = class {
3507
3877
  body: JSON.stringify(requestBody)
3508
3878
  });
3509
3879
  if (!res.ok) {
3510
- let detail = "";
3880
+ let errBody;
3511
3881
  try {
3512
- const errBody = await res.json();
3513
- detail = errBody?.message || errBody?.error || JSON.stringify(errBody);
3882
+ errBody = await res.json();
3514
3883
  } catch {
3515
3884
  }
3516
- if (res.status === 501 || res.status === 404) {
3517
- throw new AnalyticsNotInstalledError("POST /analytics/dataset/query", detail || void 0);
3885
+ const { code: serverCode, message: serverMessage } = readAnalyticsErrorEnvelope(errBody);
3886
+ const detail = serverMessage || (errBody === void 0 ? "" : JSON.stringify(errBody));
3887
+ const surface = "POST /analytics/dataset/query";
3888
+ const requestedDatasetName = typeof dataset === "string" ? dataset : typeof dataset?.name === "string" ? String(dataset.name) : void 0;
3889
+ if (errorCodeIsAnyOf({ code: serverCode }, ["NOT_IMPLEMENTED", "ROUTE_NOT_FOUND"])) {
3890
+ throw new AnalyticsNotInstalledError(surface, serverMessage, serverCode);
3891
+ }
3892
+ if (errorCodeIs({ code: serverCode }, "NOT_FOUND")) {
3893
+ throw new AnalyticsDatasetNotFoundError({
3894
+ datasetName: requestedDatasetName,
3895
+ serverCode,
3896
+ serverMessage
3897
+ });
3898
+ }
3899
+ if (errorCodeIs({ code: serverCode }, "UNAUTHENTICATED")) {
3900
+ throw new AnalyticsUnauthenticatedError({ serverCode, serverMessage });
3901
+ }
3902
+ if (serverCode === void 0) {
3903
+ if (ANALYTICS_ABSENT_STATUSES.includes(res.status)) {
3904
+ throw new AnalyticsNotInstalledError(surface, detail || void 0);
3905
+ }
3906
+ if (res.status === 401) {
3907
+ throw new AnalyticsUnauthenticatedError({ serverMessage });
3908
+ }
3518
3909
  }
3519
3910
  throw new Error(`Dataset query failed: ${res.status} ${res.statusText}${detail ? ` \u2014 ${detail}` : ""}`);
3520
3911
  }
@@ -3525,8 +3916,9 @@ var ObjectStackAdapter = class {
3525
3916
  const object = typeof data?.object === "string" ? data.object : void 0;
3526
3917
  const dimensionFields = data?.dimensionFields && typeof data.dimensionFields === "object" ? data.dimensionFields : void 0;
3527
3918
  const drillRawRows = Array.isArray(data?.drillRawRows) ? data.drillRawRows : void 0;
3919
+ const drillRanges = Array.isArray(data?.drillRanges) ? data.drillRanges : void 0;
3528
3920
  const totals = Array.isArray(data?.totals) ? data.totals : void 0;
3529
- return { rows, fields, object, dimensionFields, drillRawRows, totals };
3921
+ return { rows, fields, object, dimensionFields, drillRawRows, drillRanges, totals };
3530
3922
  }
3531
3923
  /** Client-side aggregation fallback */
3532
3924
  /**
@@ -3771,40 +4163,44 @@ function createObjectStackAdapter(config) {
3771
4163
  }
3772
4164
  // Annotate the CommonJS export names for ESM import in node:
3773
4165
  0 && (module.exports = {
4166
+ API_ACCESS_DENIED_CODES,
4167
+ APP_PERMISSION_DENIED_CODE,
4168
+ AnalyticsDatasetNotFoundError,
3774
4169
  AnalyticsNotInstalledError,
3775
4170
  AnalyticsQueryRejectedError,
4171
+ AnalyticsUnauthenticatedError,
3776
4172
  AuthenticationError,
3777
4173
  BulkOperationError,
3778
- CloudOperations,
3779
4174
  ConcurrentUpdateError,
3780
4175
  ConnectionError,
3781
4176
  DataApiValidationError,
3782
4177
  FILTER_OPERATOR_ALIASES,
3783
- IntegrationManager,
3784
4178
  MalformedFilterError,
3785
4179
  MetadataClient,
3786
4180
  MetadataNotFoundError,
3787
4181
  ObjectStackAdapter,
3788
4182
  ObjectStackError,
3789
- SecurityManager,
3790
- calculateAutoLayout,
4183
+ VIEW_OVERLAY_OWNED_KEYS,
3791
4184
  classifyAnalyticsFailure,
3792
4185
  clearSharedDiscoveryCache,
3793
- createDefaultCanvasConfig,
3794
4186
  createErrorFromResponse,
3795
4187
  createObjectStackAdapter,
3796
4188
  createObjectStackUserStateAdapter,
3797
- generateContractManifest,
4189
+ createQuietHttpLogger,
4190
+ formatHttpFailureMessage,
3798
4191
  getSharedDiscovery,
3799
4192
  is404Error,
3800
4193
  isAnalyticsNotInstalledError,
4194
+ isApiAccessDeniedError,
4195
+ isAppPermissionDeniedError,
3801
4196
  isConcurrentUpdateError,
3802
4197
  isErrorType,
3803
4198
  isMalformedFilterError,
3804
4199
  isObjectStackError,
4200
+ narrowPersonalizationOverlay,
3805
4201
  normaliseClientError,
4202
+ readSaveAdvisories,
3806
4203
  readTransactionalBatchCapability,
3807
4204
  serializeOrderBy,
3808
- snapToGrid,
3809
- validatePluginContract
4205
+ viewItemObjectName
3810
4206
  });