@object-ui/data-objectstack 17.3.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -22,11 +22,12 @@ 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,
25
27
  AnalyticsNotInstalledError: () => AnalyticsNotInstalledError,
26
28
  AnalyticsQueryRejectedError: () => AnalyticsQueryRejectedError,
27
29
  AuthenticationError: () => AuthenticationError,
28
30
  BulkOperationError: () => BulkOperationError,
29
- CloudOperations: () => CloudOperations,
30
31
  ConcurrentUpdateError: () => ConcurrentUpdateError,
31
32
  ConnectionError: () => ConnectionError,
32
33
  DataApiValidationError: () => DataApiValidationError,
@@ -45,780 +46,867 @@ __export(index_exports, {
45
46
  createErrorFromResponse: () => createErrorFromResponse,
46
47
  createObjectStackAdapter: () => createObjectStackAdapter,
47
48
  createObjectStackUserStateAdapter: () => createObjectStackUserStateAdapter,
49
+ createQuietHttpLogger: () => createQuietHttpLogger,
50
+ formatHttpFailureMessage: () => formatHttpFailureMessage,
48
51
  generateContractManifest: () => generateContractManifest,
49
52
  getSharedDiscovery: () => getSharedDiscovery,
50
53
  is404Error: () => is404Error,
51
54
  isAnalyticsNotInstalledError: () => isAnalyticsNotInstalledError,
55
+ isApiAccessDeniedError: () => isApiAccessDeniedError,
56
+ isAppPermissionDeniedError: () => isAppPermissionDeniedError,
52
57
  isConcurrentUpdateError: () => isConcurrentUpdateError,
53
58
  isErrorType: () => isErrorType,
54
59
  isMalformedFilterError: () => isMalformedFilterError,
55
60
  isObjectStackError: () => isObjectStackError,
56
61
  normaliseClientError: () => normaliseClientError,
62
+ readSaveAdvisories: () => readSaveAdvisories,
57
63
  readTransactionalBatchCapability: () => readTransactionalBatchCapability,
58
64
  serializeOrderBy: () => serializeOrderBy,
59
65
  snapToGrid: () => snapToGrid,
60
- validatePluginContract: () => validatePluginContract
66
+ validatePluginContract: () => validatePluginContract,
67
+ viewItemObjectName: () => viewItemObjectName
61
68
  });
62
69
  module.exports = __toCommonJS(index_exports);
63
70
  var import_client = require("@objectstack/client");
64
71
 
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();
72
+ // src/metadata-client.ts
73
+ function readSaveAdvisories(body) {
74
+ if (!body || typeof body !== "object") return [];
75
+ const raw = body.advisories;
76
+ if (!Array.isArray(raw)) return [];
77
+ return raw.filter((f) => {
78
+ if (!f || typeof f !== "object") return false;
79
+ const c = f;
80
+ 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";
81
+ });
73
82
  }
74
- function errorCodeIsAnyOf(err, codes) {
75
- return codes.some((c) => errorCodeIs(err, c));
83
+ var META_PREFIX = "/meta";
84
+ var API_PREFIX = "/api/v1";
85
+ function buildBase(config) {
86
+ const trimmed = config.baseUrl.replace(/\/+$/, "");
87
+ const scoped = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
88
+ if (/\/api\/v\d+$/i.test(trimmed)) {
89
+ const stripped = trimmed.replace(/\/api\/v\d+$/i, "");
90
+ const scopedNoApi = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
91
+ return `${stripped}${scopedNoApi}`;
92
+ }
93
+ return `${trimmed}${scoped}`;
76
94
  }
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";
95
+ async function parseError(res) {
96
+ let body;
97
+ try {
98
+ body = await res.json();
99
+ } catch {
100
+ body = await res.text().catch(() => void 0);
86
101
  }
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;
102
+ const bodyObj = body && typeof body === "object" ? body : void 0;
103
+ const errNode = bodyObj?.error;
104
+ const message = (errNode && typeof errNode === "object" ? errNode.message : errNode) ?? bodyObj?.message ?? `Metadata request failed: ${res.status} ${res.statusText}`;
105
+ const err = new Error(String(message));
106
+ err.status = res.status;
107
+ err.body = body;
108
+ const details = errNode && typeof errNode === "object" ? errNode.details : void 0;
109
+ const issues = details?.issues ?? (errNode && typeof errNode === "object" ? errNode.issues : void 0) ?? bodyObj?.issues;
110
+ if (Array.isArray(issues)) err.issues = issues;
111
+ const code = details?.code ?? (errNode && typeof errNode === "object" ? errNode.code : void 0) ?? bodyObj?.code;
112
+ if (code != null) err.code = String(code);
113
+ return err;
109
114
  }
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
- }
115
+ function isMetaItemEnvelope(value) {
116
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
117
+ const env = value;
118
+ return typeof env.type === "string" && typeof env.name === "string" && "item" in env;
119
+ }
120
+ var MetadataClient = class _MetadataClient {
121
+ constructor(config) {
122
+ __publicField(this, "base");
123
+ __publicField(this, "fetchImpl");
124
+ __publicField(this, "headers");
125
+ /** ADR-0037: when true, reads render the draft-overlaid world. */
126
+ __publicField(this, "previewDrafts");
127
+ /** #4133 — sink for post-save advisory findings; see the config field. */
128
+ __publicField(this, "onSaveAdvisory");
129
+ this.base = buildBase(config);
130
+ this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
131
+ this.headers = { Accept: "application/json", ...config.headers ?? {} };
132
+ this.previewDrafts = config.previewDrafts === true;
133
+ this.onSaveAdvisory = config.onSaveAdvisory;
141
134
  }
142
- if (conditions.length === 0) {
143
- return filter;
135
+ /** Update the client's environment scope at runtime. */
136
+ withEnvironment(environmentId) {
137
+ return new _MetadataClient({
138
+ baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
139
+ environmentId,
140
+ fetch: this.fetchImpl,
141
+ headers: this.headers,
142
+ previewDrafts: this.previewDrafts,
143
+ // #4133: the advisory sink must survive the clone. `useMetadataClient`
144
+ // routes EVERY environment-scoped console client through here, so
145
+ // dropping it would silently disable the channel for exactly the
146
+ // multi-environment tenants the gate is loudest for.
147
+ ...this.onSaveAdvisory ? { onSaveAdvisory: this.onSaveAdvisory } : {}
148
+ });
144
149
  }
145
- if (conditions.length === 1) {
146
- return conditions[0];
150
+ /**
151
+ * Derive a client whose reads render the draft-overlaid world (or not).
152
+ * Same base/fetch/headers; used by the app-shell to switch the whole
153
+ * renderer tree into ADR-0037 preview mode off one URL flag.
154
+ */
155
+ withPreviewDrafts(previewDrafts) {
156
+ if (previewDrafts === this.previewDrafts) return this;
157
+ const envMatch = this.base.match(/\/api\/v\d+\/environments\/([^/]+)\/meta$/);
158
+ return new _MetadataClient({
159
+ baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
160
+ ...envMatch ? { environmentId: decodeURIComponent(envMatch[1]) } : {},
161
+ fetch: this.fetchImpl,
162
+ headers: this.headers,
163
+ previewDrafts,
164
+ // #4133: carried for the same reason as in `withEnvironment` — preview
165
+ // mode swaps the whole renderer tree's client, and writes are unaffected
166
+ // by ADR-0037, so the save door behind this clone still advises.
167
+ ...this.onSaveAdvisory ? { onSaveAdvisory: this.onSaveAdvisory } : {}
168
+ });
147
169
  }
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;
170
+ /** List all registered metadata types (returns the registry rows). */
171
+ async listTypes() {
172
+ const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
173
+ if (!res.ok) throw await parseError(res);
174
+ const data = await res.json();
175
+ if (Array.isArray(data)) return data;
176
+ if (data && Array.isArray(data.entries)) return data.entries;
177
+ if (data && Array.isArray(data.items)) return data.items;
178
+ if (data && Array.isArray(data.types)) {
179
+ return data.types.map((t) => ({ type: t }));
170
180
  }
181
+ return [];
171
182
  }
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
- );
183
+ /** List items of a metadata type (e.g. `object`, `field`, `view`). */
184
+ async list(type, options = {}) {
185
+ const params = [];
186
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
187
+ if (this.previewDrafts) params.push("preview=draft");
188
+ const qs = params.length ? `?${params.join("&")}` : "";
189
+ const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
190
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
191
+ if (!res.ok) throw await parseError(res);
192
+ const data = await res.json();
193
+ if (Array.isArray(data)) return data;
194
+ if (data && Array.isArray(data.items)) {
195
+ return data.items;
212
196
  }
213
- throw err;
197
+ return [];
214
198
  }
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
199
  /**
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)
200
+ * List pending DRAFT items (ADR-0033) — what an AI authored but nobody
201
+ * published yet. `list()` only sees published/active metadata, so a
202
+ * just-built app package looks empty there; this surfaces the drafts so the
203
+ * console can show a "pending changes" view and draft-aware package contents.
204
+ * Optionally narrow by `packageId` and/or `type`. Returns light headers
205
+ * (no body) carrying `packageId` for grouping.
243
206
  */
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
- };
207
+ async listDrafts(options = {}) {
208
+ const params = [];
209
+ if (options.packageId) params.push(`packageId=${encodeURIComponent(options.packageId)}`);
210
+ if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
211
+ const qs = params.length ? `?${params.join("&")}` : "";
212
+ const url = `${this.base}/_drafts${qs}`;
213
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
214
+ if (!res.ok) throw await parseError(res);
215
+ const data = await res.json();
216
+ if (Array.isArray(data)) return data;
217
+ return data?.drafts ?? data?.data?.drafts ?? [];
260
218
  }
261
219
  /**
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
220
+ * Fetch a single metadata item and return the response body EXACTLY as the
221
+ * server sent it — envelope and all.
222
+ *
223
+ * The one transport both {@link get} (which unwraps) and {@link getDraft}
224
+ * (which does not) sit on, so the two can differ in what they hand back
225
+ * without differing in how they ask. Private on purpose: the envelope is a
226
+ * wire detail, and the two published contracts above are the supported ways
227
+ * to read an item.
267
228
  */
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;
288
- }
289
- this.stats.misses++;
290
- const promise = (async () => {
291
- try {
292
- const data = await fetcher();
293
- this.set(key, data);
294
- return data;
295
- } finally {
296
- this.inflight.delete(key);
297
- }
298
- })();
299
- this.inflight.set(key, promise);
300
- return promise;
229
+ async readItemResponse(type, name, options = {}) {
230
+ const params = [];
231
+ if (options.state === "draft") params.push("state=draft");
232
+ else if (this.previewDrafts) params.push("preview=draft");
233
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
234
+ const qs = params.length ? `?${params.join("&")}` : "";
235
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
236
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
237
+ if (res.status === 404) return null;
238
+ if (!res.ok) throw await parseError(res);
239
+ return await res.json();
301
240
  }
302
241
  /**
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.
242
+ * Get a single metadata item. Returns the unwrapped item CONTENT — the
243
+ * metadata document itself, so `obj.fields` / `obj.label` are reachable
244
+ * directly.
245
+ *
246
+ * The framework answers `GET /meta/:type/:name` with the spec-declared
247
+ * envelope `{ type, name, item, …protection fields }`
248
+ * (`GetMetaItemResponseSchema`; objectstack#5563 collapsed this read to that
249
+ * ONE shape), so the envelope is unwrapped here — at the client boundary,
250
+ * once — rather than by each caller.
251
+ *
252
+ * **This method used to return the envelope while promising the body**
253
+ * (objectui#4271). Nothing detected the disagreement, because the test
254
+ * doubles across the repo were written against this docblock: every consumer
255
+ * reading `obj.fields` got `undefined` in production and a field list in
256
+ * unit tests. The visible cost was the entire field half of the permission
257
+ * matrix reporting "No fields registered for this object." for every object,
258
+ * plus dead RLS CEL autocomplete, an inert report drill-down and a designer
259
+ * that saved the envelope back over the object body.
260
+ *
261
+ * A response that is NOT the envelope (an older server answering the bare
262
+ * document, or a body of its own shape) passes through untouched, and `null`
263
+ * still comes back on 404 to keep the call site ergonomic.
306
264
  */
307
- prime(key, data) {
308
- this.set(key, data);
265
+ async get(type, name, options = {}) {
266
+ const resp = await this.readItemResponse(type, name, options);
267
+ if (isMetaItemEnvelope(resp)) return resp.item ?? null;
268
+ return resp;
309
269
  }
310
270
  /**
311
- * Set a value in the cache
312
- *
313
- * @param key - Cache key
314
- * @param data - Data to cache
271
+ * Read the pending draft body for an item (`?state=draft`). Returns
272
+ * `null` when there is no draft pending. Draft reads do NOT fall
273
+ * back to the published overlay or the artifact registry — a `null`
274
+ * unambiguously means "nothing to publish".
275
+ *
276
+ * Note: this method hands back the `{ type, name, item }` envelope the
277
+ * framework sends, NOT the body — callers read `.item`. That asymmetry with
278
+ * {@link get} is deliberate and long-standing (the draft envelope's identity
279
+ * and protection carriers are part of what a draft reader inspects), so it
280
+ * is preserved by reading the transport directly instead of going through
281
+ * `get()`'s unwrap. `unwrapDraftBody` (app-shell) and `unwrapViewDraft`
282
+ * (this package) are the shared helpers for taking the body out.
315
283
  */
316
- set(key, data) {
317
- const now = Date.now();
318
- if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
319
- this.evictLRU();
284
+ async getDraft(type, name, options = {}) {
285
+ return this.readItemResponse(type, name, {
286
+ state: "draft",
287
+ ...options.packageId ? { packageId: options.packageId } : {}
288
+ });
289
+ }
290
+ /**
291
+ * Save (PUT) a metadata item. The framework accepts both the bare
292
+ * item payload and the `{ item: ... }` / `{ metadata: ... }`
293
+ * envelopes; we send bare for consistency. Pass `mode: 'draft'` to
294
+ * stage the change without publishing (Studio's "Save" button).
295
+ */
296
+ async save(type, name, item, options = {}) {
297
+ if (!name || !String(name).trim()) {
298
+ throw new Error(
299
+ `MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
300
+ );
320
301
  }
321
- this.cache.set(key, {
322
- data,
323
- timestamp: now,
324
- accessCount: 1,
325
- lastAccessed: now
302
+ const params = [];
303
+ if (options.force) params.push("force=true");
304
+ if (options.mode === "draft") params.push("mode=draft");
305
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
306
+ const qs = params.length ? `?${params.join("&")}` : "";
307
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
308
+ const headers = {
309
+ ...this.headers,
310
+ "Content-Type": "application/json"
311
+ };
312
+ if (options.ifMatch) headers["If-Match"] = options.ifMatch;
313
+ if (options.actor) headers["X-Actor"] = options.actor;
314
+ const res = await this.fetchImpl(url, {
315
+ method: "PUT",
316
+ headers,
317
+ body: JSON.stringify(item)
318
+ });
319
+ if (!res.ok) throw await parseError(res);
320
+ const body = await res.json();
321
+ if (this.onSaveAdvisory) {
322
+ try {
323
+ const advisories = readSaveAdvisories(body);
324
+ if (advisories.length > 0) {
325
+ this.onSaveAdvisory({
326
+ type,
327
+ name,
328
+ mode: options.mode === "draft" ? "draft" : "publish",
329
+ advisories
330
+ });
331
+ }
332
+ } catch {
333
+ }
334
+ }
335
+ return body;
336
+ }
337
+ /**
338
+ * Publish a single pending draft BY REFERENCE — promotes the draft row for
339
+ * `(type, name)` into the active overlay and drops the draft. Works for ANY
340
+ * draft, including ones with no `packageId` binding (which the package-scoped
341
+ * `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
342
+ * exact set returned by {@link listDrafts} without needing a package.
343
+ *
344
+ * Returns the server result. For `seed` drafts the protocol also materializes
345
+ * the rows and reports under `seedApplied` — a data problem never fails the
346
+ * publish, so callers should check `seedApplied?.success` and warn the user
347
+ * rather than assume the data went live.
348
+ */
349
+ async publishDraft(type, name) {
350
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
351
+ const res = await this.fetchImpl(url, {
352
+ method: "POST",
353
+ headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
354
+ body: "{}"
326
355
  });
356
+ if (!res.ok) throw await parseError(res);
357
+ const body = await res.json().catch(() => ({}));
358
+ const inner = body?.data && typeof body.data === "object" ? body.data : body;
359
+ return inner;
327
360
  }
328
361
  /**
329
- * Evict the least recently used entry
362
+ * Get the 3-state layered view of a metadata item (Phase 3a). Returns
363
+ * `code` (the artifact / fallback default), `overlay` (the saved
364
+ * customisation, if any), and `effective` (what the runtime sees).
330
365
  */
331
- evictLRU() {
332
- const firstKey = this.cache.keys().next().value;
333
- if (firstKey !== void 0) {
334
- this.cache.delete(firstKey);
335
- this.stats.evictions++;
366
+ async layered(type, name, options = {}) {
367
+ const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : "";
368
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
369
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
370
+ if (res.status === 404) {
371
+ return { code: null, overlay: null, overlayScope: null, effective: null };
372
+ }
373
+ if (!res.ok) throw await parseError(res);
374
+ const body = await res.json();
375
+ const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
376
+ if (!hasEnvelope) {
377
+ return {
378
+ code: null,
379
+ overlay: null,
380
+ overlayScope: null,
381
+ effective: body
382
+ };
336
383
  }
384
+ return {
385
+ code: body.code ?? null,
386
+ overlay: body.overlay ?? null,
387
+ overlayScope: body.overlayScope ?? null,
388
+ effective: body.effective ?? null,
389
+ ...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
390
+ // ADR-0010 Phase 4 — pass through the metadata-protection envelope so
391
+ // the editor can render lock affordances without a second round trip.
392
+ ...body.lock !== void 0 ? { lock: body.lock } : {},
393
+ ...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
394
+ ...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
395
+ ...body.lockDocsUrl !== void 0 ? { lockDocsUrl: body.lockDocsUrl } : {},
396
+ ...body.provenance !== void 0 ? { provenance: body.provenance } : {},
397
+ ...body.packageId !== void 0 ? { packageId: body.packageId } : {},
398
+ ...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
399
+ ...body.editable !== void 0 ? { editable: body.editable } : {},
400
+ ...body.deletable !== void 0 ? { deletable: body.deletable } : {},
401
+ ...body.resettable !== void 0 ? { resettable: body.resettable } : {}
402
+ };
337
403
  }
338
404
  /**
339
- * Invalidate a specific cache entry or all entries
340
- *
341
- * @param key - Optional key to invalidate. If omitted, invalidates all entries
405
+ * Cross-type sweep of load-time validation results — calls
406
+ * `GET /meta/diagnostics`. Returns every entry the framework
407
+ * considers invalid (or, when `severity: 'warning'`, also entries
408
+ * with only warnings).
409
+ *
410
+ * Used by the Studio's governance overview page and by the
411
+ * directory page to show "N invalid" badges per metadata type.
342
412
  */
343
- invalidate(key) {
344
- if (key) {
345
- this.cache.delete(key);
346
- } else {
347
- this.cache.clear();
413
+ async diagnostics(options = {}) {
414
+ const params = [];
415
+ if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
416
+ if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
417
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
418
+ const qs = params.length ? `?${params.join("&")}` : "";
419
+ const url = `${this.base}/diagnostics${qs}`;
420
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
421
+ if (res.status === 404) {
422
+ return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
348
423
  }
424
+ if (!res.ok) throw await parseError(res);
425
+ const data = await res.json();
426
+ return {
427
+ entries: Array.isArray(data.entries) ? data.entries : [],
428
+ total: typeof data.total === "number" ? data.total : 0,
429
+ scannedTypes: typeof data.scannedTypes === "number" ? data.scannedTypes : 0,
430
+ scannedItems: typeof data.scannedItems === "number" ? data.scannedItems : 0,
431
+ stats: data.stats && typeof data.stats === "object" ? data.stats : {}
432
+ };
349
433
  }
350
434
  /**
351
- * Clear all cache entries and reset statistics
435
+ * Find every metadata item that references this one (Phase 3a). Useful
436
+ * for pre-delete impact analysis: "Are any views pointing at this
437
+ * object before I drop it?".
352
438
  */
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
361
- };
439
+ async references(type, name) {
440
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
441
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
442
+ if (res.status === 404) return [];
443
+ if (!res.ok) throw await parseError(res);
444
+ const data = await res.json();
445
+ return Array.isArray(data) ? data : data?.items ?? [];
362
446
  }
363
447
  /**
364
- * Get cache statistics
365
- *
366
- * @returns Cache statistics including hit rate
448
+ * Reset a metadata customization overlay back to the artifact default.
449
+ * Idempotent: returns the result even when no overlay row existed.
450
+ * Pass `state: 'draft'` to discard the pending draft only (keeps the
451
+ * published overlay intact) — useful for a Studio "Discard draft" button.
367
452
  */
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
379
- };
453
+ async reset(type, name, options = {}) {
454
+ const qs = options.state === "draft" ? "?state=draft" : "";
455
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
456
+ const headers = { ...this.headers };
457
+ if (options.ifMatch) headers["If-Match"] = options.ifMatch;
458
+ if (options.actor) headers["X-Actor"] = options.actor;
459
+ const res = await this.fetchImpl(url, { method: "DELETE", headers });
460
+ if (!res.ok) throw await parseError(res);
461
+ return await res.json();
380
462
  }
381
463
  /**
382
- * Get a cached value synchronously without triggering a fetch.
383
- * Returns undefined if not in cache or expired.
464
+ * Promote the pending draft of an item to the active overlay
465
+ * (POST `/meta/:type/:name/publish`). Returns
466
+ * `{ success, version, seq, message }`. Throws a `404 no_draft` if
467
+ * nothing is pending and `409 metadata_conflict` if the published
468
+ * overlay moved while the draft was sitting.
384
469
  */
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;
470
+ async publish(type, name, options = {}) {
471
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
472
+ const headers = {
473
+ ...this.headers,
474
+ "Content-Type": "application/json"
475
+ };
476
+ if (options.actor) headers["X-Actor"] = options.actor;
477
+ const res = await this.fetchImpl(url, {
478
+ method: "POST",
479
+ headers,
480
+ body: JSON.stringify(options.message ? { message: options.message } : {})
481
+ });
482
+ if (!res.ok) throw await parseError(res);
483
+ return await res.json();
396
484
  }
397
485
  /**
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
486
+ * Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
487
+ * The server reads the history row at `toVersion`, writes its body back
488
+ * as the active overlay with `operation_type='revert'`. Returns
489
+ * `{ success, version, seq, restoredFromVersion, message }`. Throws
490
+ * `404 version_not_found` for unknown versions and `409 version_not_restorable`
491
+ * when the target version is a delete tombstone.
402
492
  */
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;
414
- }
415
- };
416
-
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}`;
429
- }
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;
449
- }
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
493
+ async rollback(type, name, toVersion, options = {}) {
494
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
495
+ const headers = {
496
+ ...this.headers,
497
+ "Content-Type": "application/json"
498
+ };
499
+ if (options.actor) headers["X-Actor"] = options.actor;
500
+ const res = await this.fetchImpl(url, {
501
+ method: "POST",
502
+ headers,
503
+ body: JSON.stringify({
504
+ toVersion,
505
+ ...options.message ? { message: options.message } : {}
506
+ })
470
507
  });
508
+ if (!res.ok) throw await parseError(res);
509
+ return await res.json();
471
510
  }
472
511
  /**
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.
512
+ * Compute a structured top-level key diff between two history versions
513
+ * (GET `/meta/:type/:name/diff?from=&to=`). Returns
514
+ * `{ added, removed, changed }` where each entry carries a `path` and
515
+ * the relevant value(s). Useful for "what changed in this draft?" and
516
+ * pre-rollback previews.
476
517
  */
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,
518
+ async diff(type, name, fromVersion, toVersion) {
519
+ const params = new URLSearchParams();
520
+ if (fromVersion !== void 0) params.set("from", String(fromVersion));
521
+ if (toVersion !== void 0) params.set("to", String(toVersion));
522
+ const qs = params.toString();
523
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ""}`;
524
+ const res = await this.fetchImpl(url, {
525
+ method: "GET",
484
526
  headers: this.headers,
485
- previewDrafts
527
+ cache: "no-store"
486
528
  });
487
- }
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 }));
498
- }
499
- return [];
500
- }
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
529
  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 [];
530
+ return await res.json();
516
531
  }
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}`;
532
+ /** Fetch the durable history (change log) for one metadata item. */
533
+ async history(type, name, options = {}) {
534
+ const params = new URLSearchParams();
535
+ if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
536
+ if (options.limit !== void 0) params.set("limit", String(options.limit));
537
+ const qs = params.toString();
538
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
531
539
  const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
532
540
  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 ?? [];
541
+ return await res.json();
536
542
  }
537
543
  /**
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.
544
+ * Fetch the protection-audit trail for one metadata item
545
+ * (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
546
+ * delete/reset attempt — allowed, denied, or forced — so the
547
+ * Studio "审计日志 / Audit log" tab can render who tried what
548
+ * and whether a lock blocked it.
541
549
  */
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}`;
550
+ async audit(type, name, options = {}) {
551
+ const params = new URLSearchParams();
552
+ if (options.limit !== void 0) params.set("limit", String(options.limit));
553
+ const qs = params.toString();
554
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ""}`;
549
555
  const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
550
- if (res.status === 404) return null;
551
556
  if (!res.ok) throw await parseError(res);
552
557
  return await res.json();
553
558
  }
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 } : {} });
559
+ };
560
+
561
+ // ../types/src/index.ts
562
+ var import_spec = require("@objectstack/spec");
563
+
564
+ // ../types/src/error-code.ts
565
+ function errorCodeIs(err, code) {
566
+ const raw = err?.code;
567
+ if (typeof raw !== "string" || raw.length === 0) return false;
568
+ return raw.toUpperCase() === code.toUpperCase();
569
+ }
570
+ function errorCodeIsAnyOf(err, codes) {
571
+ return codes.some((c) => errorCodeIs(err, c));
572
+ }
573
+
574
+ // ../core/src/utils/filter-converter.ts
575
+ var import_ui = require("@objectstack/spec/ui");
576
+ var FilterOperatorError = class extends Error {
577
+ constructor(message) {
578
+ super(message);
579
+ __publicField(this, "code", "INVALID_FILTER");
580
+ __publicField(this, "httpStatus", 400);
581
+ this.name = "FilterOperatorError";
568
582
  }
569
- /**
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).
574
- */
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.`
583
+ };
584
+ function convertOperatorToAST(operator) {
585
+ const operatorMap = {
586
+ "$eq": "=",
587
+ "$ne": "!=",
588
+ "$gt": ">",
589
+ "$gte": ">=",
590
+ "$lt": "<",
591
+ "$lte": "<=",
592
+ "$in": "in",
593
+ "$nin": "nin",
594
+ "$notin": "nin",
595
+ "$between": "between",
596
+ "$contains": "contains",
597
+ "$notContains": "notcontains",
598
+ "$notcontains": "notcontains",
599
+ "$startsWith": "startswith",
600
+ "$startswith": "startswith",
601
+ "$endsWith": "endswith",
602
+ "$endswith": "endswith"
603
+ };
604
+ return operatorMap[operator] || null;
605
+ }
606
+ function convertFiltersToAST(filter) {
607
+ const conditions = [];
608
+ for (const [field, value] of Object.entries(filter)) {
609
+ if (value === null || value === void 0) continue;
610
+ if (typeof value === "object" && !Array.isArray(value)) {
611
+ for (const [operator, operatorValue] of Object.entries(value)) {
612
+ if (operator === "$regex") {
613
+ throw new FilterOperatorError(
614
+ `[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.`
615
+ );
616
+ }
617
+ if (operator === "$null") {
618
+ conditions.push([field, operatorValue ? "is_null" : "is_not_null", true]);
619
+ continue;
620
+ }
621
+ if (operator === "$exists") {
622
+ conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
623
+ continue;
624
+ }
625
+ const astOperator = convertOperatorToAST(operator);
626
+ if (astOperator) {
627
+ conditions.push([field, astOperator, operatorValue]);
628
+ } else {
629
+ throw new FilterOperatorError(
630
+ `[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.`
631
+ );
632
+ }
633
+ }
634
+ } else {
635
+ conditions.push([field, "=", value]);
636
+ }
637
+ }
638
+ if (conditions.length === 0) {
639
+ return filter;
640
+ }
641
+ if (conditions.length === 1) {
642
+ return conditions[0];
643
+ }
644
+ return ["and", ...conditions];
645
+ }
646
+
647
+ // ../core/src/adapters/batchTransaction.ts
648
+ function idOf(rec) {
649
+ if (rec == null) return void 0;
650
+ return rec.id ?? rec._id ?? rec.recordId;
651
+ }
652
+ function resolveRefs(data, results) {
653
+ const out = {};
654
+ for (const [key, value] of Object.entries(data)) {
655
+ if (value && typeof value === "object" && !Array.isArray(value) && typeof value.$ref === "number") {
656
+ const refIndex = value.$ref;
657
+ const id = idOf(results[refIndex]);
658
+ if (id === void 0) {
659
+ throw new Error(
660
+ `batchTransaction: field "${key}" references operation ${refIndex}, which has not produced an id (forward or invalid $ref)`
661
+ );
662
+ }
663
+ out[key] = id;
664
+ } else {
665
+ out[key] = value;
666
+ }
667
+ }
668
+ return out;
669
+ }
670
+ async function emulateBatchTransaction(dataSource, operations) {
671
+ const results = [];
672
+ const created = [];
673
+ try {
674
+ for (const op of operations) {
675
+ const action = op.action ?? "create";
676
+ if (action === "create") {
677
+ const record = await dataSource.create(
678
+ op.object,
679
+ resolveRefs(op.data ?? {}, results)
680
+ );
681
+ results.push(record);
682
+ const id = idOf(record);
683
+ if (id) created.push({ object: op.object, id });
684
+ } else if (action === "update") {
685
+ if (op.id == null) {
686
+ throw new Error(`batchTransaction: update on "${op.object}" requires an id`);
687
+ }
688
+ results.push(
689
+ await dataSource.update(
690
+ op.object,
691
+ op.id,
692
+ resolveRefs(op.data ?? {}, results)
693
+ )
694
+ );
695
+ } else {
696
+ if (op.id == null) {
697
+ throw new Error(`batchTransaction: delete on "${op.object}" requires an id`);
698
+ }
699
+ results.push(await dataSource.delete(op.object, op.id));
700
+ }
701
+ }
702
+ return { results };
703
+ } catch (err) {
704
+ if (created.length > 0) {
705
+ await Promise.allSettled(
706
+ [...created].reverse().map((c) => dataSource.delete(c.object, c.id))
579
707
  );
580
708
  }
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"
590
- };
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();
709
+ throw err;
600
710
  }
711
+ }
712
+
713
+ // ../core/src/utils/reference-keys.ts
714
+ function normalizeFieldReferenceKeys(fieldDef) {
715
+ if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
716
+ const f = fieldDef;
717
+ const target = f.reference_to ?? f.reference ?? f.referenceTo;
718
+ if (target == null || target === "") return fieldDef;
719
+ if (f.reference_to === void 0) f.reference_to = target;
720
+ if (f.reference === void 0) f.reference = target;
721
+ return fieldDef;
722
+ }
723
+ function normalizeSchemaReferenceKeys(schema) {
724
+ const fields = schema && typeof schema === "object" ? schema.fields : null;
725
+ if (!fields || typeof fields !== "object") return schema;
726
+ const defs = Array.isArray(fields) ? fields : Object.values(fields);
727
+ for (const def of defs) normalizeFieldReferenceKeys(def);
728
+ return schema;
729
+ }
730
+
731
+ // src/cache/MetadataCache.ts
732
+ var MetadataCache = class {
601
733
  /**
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.
734
+ * Create a new MetadataCache instance
735
+ *
736
+ * @param options - Configuration options
737
+ * @param options.maxSize - Maximum number of entries (default: 100)
738
+ * @param options.ttl - Time to live in milliseconds (default: 5 minutes)
612
739
  */
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;
740
+ constructor(options = {}) {
741
+ __publicField(this, "cache");
742
+ __publicField(this, "inflight");
743
+ __publicField(this, "maxSize");
744
+ __publicField(this, "ttl");
745
+ __publicField(this, "stats");
746
+ this.cache = /* @__PURE__ */ new Map();
747
+ this.inflight = /* @__PURE__ */ new Map();
748
+ this.maxSize = options.maxSize || 100;
749
+ this.ttl = options.ttl || 5 * 60 * 1e3;
750
+ this.stats = {
751
+ hits: 0,
752
+ misses: 0,
753
+ evictions: 0,
754
+ coalesced: 0
755
+ };
624
756
  }
625
757
  /**
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).
758
+ * Get a value from cache or fetch it using the provided fetcher function
759
+ *
760
+ * @param key - Cache key
761
+ * @param fetcher - Async function to fetch data if not in cache
762
+ * @returns Promise resolving to the cached or fetched data
629
763
  */
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 };
764
+ async get(key, fetcher) {
765
+ const now = Date.now();
766
+ const cached = this.cache.get(key);
767
+ if (cached) {
768
+ const age = now - cached.timestamp;
769
+ if (age < this.ttl) {
770
+ cached.accessCount++;
771
+ cached.lastAccessed = now;
772
+ this.stats.hits++;
773
+ this.cache.delete(key);
774
+ this.cache.set(key, cached);
775
+ return cached.data;
776
+ } else {
777
+ this.cache.delete(key);
778
+ }
636
779
  }
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
- };
780
+ const existing = this.inflight.get(key);
781
+ if (existing) {
782
+ this.stats.coalesced++;
783
+ return existing;
647
784
  }
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
- };
785
+ this.stats.misses++;
786
+ const promise = (async () => {
787
+ try {
788
+ const data = await fetcher();
789
+ this.set(key, data);
790
+ return data;
791
+ } finally {
792
+ this.inflight.delete(key);
793
+ }
794
+ })();
795
+ this.inflight.set(key, promise);
796
+ return promise;
667
797
  }
668
798
  /**
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.
799
+ * Prime the cache with a pre-fetched value. Useful when a bulk endpoint
800
+ * (e.g. list of all object schemas) returns data that would otherwise
801
+ * be fetched again per item.
676
802
  */
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: {} };
803
+ prime(key, data) {
804
+ this.set(key, data);
805
+ }
806
+ /**
807
+ * Set a value in the cache
808
+ *
809
+ * @param key - Cache key
810
+ * @param data - Data to cache
811
+ */
812
+ set(key, data) {
813
+ const now = Date.now();
814
+ if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
815
+ this.evictLRU();
687
816
  }
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
- };
817
+ this.cache.set(key, {
818
+ data,
819
+ timestamp: now,
820
+ accessCount: 1,
821
+ lastAccessed: now
822
+ });
697
823
  }
698
824
  /**
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?".
825
+ * Evict the least recently used entry
702
826
  */
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 ?? [];
827
+ evictLRU() {
828
+ const firstKey = this.cache.keys().next().value;
829
+ if (firstKey !== void 0) {
830
+ this.cache.delete(firstKey);
831
+ this.stats.evictions++;
832
+ }
710
833
  }
711
834
  /**
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.
835
+ * Invalidate a specific cache entry or all entries
836
+ *
837
+ * @param key - Optional key to invalidate. If omitted, invalidates all entries
716
838
  */
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();
839
+ invalidate(key) {
840
+ if (key) {
841
+ this.cache.delete(key);
842
+ } else {
843
+ this.cache.clear();
844
+ }
726
845
  }
727
846
  /**
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.
847
+ * Clear all cache entries and reset statistics
733
848
  */
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"
849
+ clear() {
850
+ this.cache.clear();
851
+ this.inflight.clear();
852
+ this.stats = {
853
+ hits: 0,
854
+ misses: 0,
855
+ evictions: 0,
856
+ coalesced: 0
739
857
  };
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
858
  }
749
859
  /**
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.
860
+ * Get cache statistics
861
+ *
862
+ * @returns Cache statistics including hit rate
756
863
  */
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"
864
+ getStats() {
865
+ const total = this.stats.hits + this.stats.misses;
866
+ const hitRate = total > 0 ? this.stats.hits / total : 0;
867
+ return {
868
+ size: this.cache.size,
869
+ maxSize: this.maxSize,
870
+ hits: this.stats.hits,
871
+ misses: this.stats.misses,
872
+ evictions: this.stats.evictions,
873
+ coalesced: this.stats.coalesced,
874
+ hitRate
762
875
  };
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
876
  }
775
877
  /**
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.
878
+ * Get a cached value synchronously without triggering a fetch.
879
+ * Returns undefined if not in cache or expired.
781
880
  */
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();
881
+ getCachedSync(key) {
882
+ const entry = this.cache.get(key);
883
+ if (!entry) return void 0;
884
+ if (this.ttl > 0 && Date.now() - entry.timestamp > this.ttl) {
885
+ this.cache.delete(key);
886
+ return void 0;
887
+ }
888
+ this.cache.delete(key);
889
+ this.cache.set(key, entry);
890
+ this.stats.hits++;
891
+ return entry.data;
806
892
  }
807
893
  /**
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.
894
+ * Check if a key exists in the cache (and is not expired)
895
+ *
896
+ * @param key - Cache key to check
897
+ * @returns true if the key exists and is not expired
813
898
  */
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();
899
+ has(key) {
900
+ const cached = this.cache.get(key);
901
+ if (!cached) {
902
+ return false;
903
+ }
904
+ const age = Date.now() - cached.timestamp;
905
+ if (age >= this.ttl) {
906
+ this.cache.delete(key);
907
+ return false;
908
+ }
909
+ return true;
822
910
  }
823
911
  };
824
912
 
@@ -1009,45 +1097,6 @@ function isErrorType(error, errorClass) {
1009
1097
  return error instanceof errorClass;
1010
1098
  }
1011
1099
 
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
1100
  // src/contracts.ts
1052
1101
  function validatePluginContract(contract) {
1053
1102
  const errors = [];
@@ -1632,6 +1681,17 @@ function is404Error(error) {
1632
1681
  const code = typeof err.code === "string" ? err.code : "";
1633
1682
  return errorCodeIsAnyOf({ code }, ["OBJECT_NOT_FOUND", "RECORD_NOT_FOUND"]);
1634
1683
  }
1684
+ var API_ACCESS_DENIED_CODES = [
1685
+ "OBJECT_API_DISABLED",
1686
+ "OBJECT_API_METHOD_NOT_ALLOWED"
1687
+ ];
1688
+ function isApiAccessDeniedError(error) {
1689
+ return errorCodeIsAnyOf(error, API_ACCESS_DENIED_CODES);
1690
+ }
1691
+ var APP_PERMISSION_DENIED_CODE = "PERMISSION_DENIED";
1692
+ function isAppPermissionDeniedError(error) {
1693
+ return errorCodeIs(error, APP_PERMISSION_DENIED_CODE);
1694
+ }
1635
1695
  var AnalyticsNotInstalledError = class extends Error {
1636
1696
  constructor(surface, detail) {
1637
1697
  super(
@@ -1716,6 +1776,19 @@ function normaliseClientError(error) {
1716
1776
  message: typeof e.message === "string" ? e.message : void 0
1717
1777
  });
1718
1778
  }
1779
+ function formatHttpFailureMessage(message, meta) {
1780
+ if (!meta || typeof meta !== "object") return null;
1781
+ const method = typeof meta.method === "string" && meta.method ? meta.method : void 0;
1782
+ const url = typeof meta.url === "string" && meta.url ? meta.url : void 0;
1783
+ const status = typeof meta.status === "number" ? meta.status : typeof meta.statusCode === "number" ? meta.statusCode : void 0;
1784
+ if (!method && !url && status === void 0) return null;
1785
+ const errBody = meta.error && typeof meta.error === "object" ? meta.error : void 0;
1786
+ const rawCode = typeof meta.code === "string" && meta.code || (errBody && typeof errBody.code === "string" ? errBody.code : void 0);
1787
+ const parts = [method ?? "GET", url ?? "<unknown url>"];
1788
+ parts.push(`-> ${status ?? "no status"}`);
1789
+ if (rawCode) parts.push(`[${rawCode}]`);
1790
+ return `${message}: ${parts.join(" ")}`;
1791
+ }
1719
1792
  function createQuietHttpLogger() {
1720
1793
  const isExpected404 = (meta) => {
1721
1794
  if (!meta || typeof meta !== "object") return false;
@@ -1733,12 +1806,15 @@ function createQuietHttpLogger() {
1733
1806
  warn: (message, meta) => console.warn(message, meta ?? ""),
1734
1807
  error: (message, error, meta) => {
1735
1808
  if (isExpected404(meta)) {
1736
- console.debug(`[ObjectStack] ${message} (suppressed expected 404)`, meta);
1809
+ console.debug(
1810
+ `[ObjectStack] ${formatHttpFailureMessage(message, meta) ?? message} (suppressed expected 404)`,
1811
+ meta
1812
+ );
1737
1813
  return;
1738
1814
  }
1739
- console.error(message, error ?? "", meta ?? "");
1815
+ console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? "", meta ?? "");
1740
1816
  },
1741
- fatal: (message, error, meta) => console.error(message, error ?? "", meta ?? ""),
1817
+ fatal: (message, error, meta) => console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? "", meta ?? ""),
1742
1818
  log: (message, ...args) => console.log(message, ...args),
1743
1819
  child: () => logger,
1744
1820
  withTrace: () => logger
@@ -1777,6 +1853,35 @@ function withoutNoOpDrops(droppedFields, sent, stored) {
1777
1853
  }
1778
1854
  return out;
1779
1855
  }
1856
+ function viewItemObjectName(item) {
1857
+ const spec = item?.list ?? item;
1858
+ return spec?.data?.object ?? spec?.object ?? spec?.objectName;
1859
+ }
1860
+ function unwrapViewDraft(resp) {
1861
+ if (!resp || typeof resp !== "object") return null;
1862
+ const env = resp;
1863
+ const body = "item" in env ? env.item : env;
1864
+ if (!body || typeof body !== "object") return null;
1865
+ const spec = body.list ?? body;
1866
+ if (!spec || typeof spec !== "object") return null;
1867
+ return Object.keys(spec).length > 0 ? spec : null;
1868
+ }
1869
+ function readViewDeleteReceipt(result) {
1870
+ const r = result ?? void 0;
1871
+ return {
1872
+ removed: !!(r?.deleted ?? r?.reset ?? true),
1873
+ ...typeof r?.reset === "boolean" ? { reset: r.reset } : {},
1874
+ ...typeof r?.message === "string" ? { message: r.message } : {}
1875
+ };
1876
+ }
1877
+ function mergeViewPatch(current, partial, viewName, objectName) {
1878
+ return {
1879
+ ...current,
1880
+ ...partial,
1881
+ name: viewName,
1882
+ object: current?.object || current?.data?.object || objectName
1883
+ };
1884
+ }
1780
1885
  var ObjectStackAdapter = class {
1781
1886
  constructor(config) {
1782
1887
  __publicField(this, "client");
@@ -1829,7 +1934,14 @@ var ObjectStackAdapter = class {
1829
1934
  // whose response carried `droppedFields` (framework #3431/#3455) so the app
1830
1935
  // shell can surface a toast instead of the strip passing silently.
1831
1936
  __publicField(this, "writeWarningListeners", /* @__PURE__ */ new Set());
1937
+ // Subscribers registered via onSaveAdvisory(). Emitted after a metadata save
1938
+ // through THIS adapter's `ObjectStackClient` whose 200 carried a non-empty
1939
+ // `advisories` array (#4237; backend objectstack#7435). Sibling of the set
1940
+ // above in every respect except which door produced the event: that one is
1941
+ // record CRUD, this one is the metadata save door.
1942
+ __publicField(this, "saveAdvisoryListeners", /* @__PURE__ */ new Set());
1832
1943
  this.client = new import_client.ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
1944
+ this.installSaveAdvisoryInterceptor();
1833
1945
  this.metadataCache = new MetadataCache(config.cache);
1834
1946
  this.autoReconnect = config.autoReconnect ?? true;
1835
1947
  this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3;
@@ -1984,7 +2096,7 @@ var ObjectStackAdapter = class {
1984
2096
  const result = await this.client.data.find(resource, queryOptions);
1985
2097
  return this.normalizeQueryResult(result, params);
1986
2098
  } catch (err) {
1987
- if (is404Error(err)) {
2099
+ if (!isApiAccessDeniedError(err) && is404Error(err)) {
1988
2100
  this.missingResources.add(resource);
1989
2101
  return { data: [], total: 0 };
1990
2102
  }
@@ -2212,6 +2324,115 @@ var ObjectStackAdapter = class {
2212
2324
  this.writeWarningListeners.delete(callback);
2213
2325
  };
2214
2326
  }
2327
+ /**
2328
+ * Subscribe to metadata save-advisory events — the runtime authoring gate's
2329
+ * advisory findings on a save that SUCCEEDED (#4237; backend
2330
+ * objectstack#7435). Returns an unsubscribe function.
2331
+ *
2332
+ * Deliberately the same seam as {@link onWriteWarning} (#3431/#3455), which
2333
+ * is what {@link MetadataSaveAdvisoryEvent}'s own declaration already said it
2334
+ * was modelled on. It is a SIBLING of that channel rather than a second
2335
+ * payload pushed down it: `WriteWarningEvent` is a closed shape whose
2336
+ * `droppedFields` is required and means "fields the write legally stripped",
2337
+ * so carrying advisories on it would either force every existing
2338
+ * `onWriteWarning` consumer to grow a branch or make the event lie about what
2339
+ * happened. The seam's SHAPE is what is reused here — a long-lived instance
2340
+ * with a `subscribe → unsubscribe` registration that `AdapterProvider` wires
2341
+ * once — not its event type.
2342
+ *
2343
+ * Why here and not on the config, which is how the other client class does it
2344
+ * (#4133/#4236): `MetadataClient` is minted per component by
2345
+ * `useMetadataClient`, so it has no instance to subscribe to and its sink
2346
+ * rides the factory. `ObjectStackAdapter` is the opposite — one long-lived
2347
+ * instance per app, already carrying this exact subscription pattern.
2348
+ */
2349
+ onSaveAdvisory(callback) {
2350
+ this.saveAdvisoryListeners.add(callback);
2351
+ return () => {
2352
+ this.saveAdvisoryListeners.delete(callback);
2353
+ };
2354
+ }
2355
+ /**
2356
+ * Notify all save-advisory subscribers. Isolated exactly like
2357
+ * {@link emitWriteWarning}: a throwing listener must neither break the save
2358
+ * nor starve the others.
2359
+ */
2360
+ emitSaveAdvisory(event) {
2361
+ for (const listener of this.saveAdvisoryListeners) {
2362
+ try {
2363
+ listener(event);
2364
+ } catch (err) {
2365
+ console.warn("ObjectStackAdapter: save-advisory listener error", err);
2366
+ }
2367
+ }
2368
+ }
2369
+ /**
2370
+ * Install the ONE emitter for the metadata save door (#4237).
2371
+ *
2372
+ * ## Why this seam, and what it covers
2373
+ *
2374
+ * `ObjectStackClient.meta.saveItem` is the second client class that writes
2375
+ * through `PUT /api/v1/meta/:type/:name`, and every one of its callers reaches
2376
+ * it through an adapter this class constructed — the four inside this file
2377
+ * (`updateViewConfig`, the two view paths, `updateDashboard`) via
2378
+ * `this.client`, and every caller outside it via {@link getClient}, which
2379
+ * hands back this same instance: `MetadataService` (app-shell, five saves),
2380
+ * `useNavigationSync`, and plugin-designer's Create/EditAppPage. Wrapping the
2381
+ * method once here therefore covers all of them WITHOUT a per-site edit, which
2382
+ * is the whole point — a toast copied into a dozen call sites is the shape
2383
+ * #4133 rejected for the other client class and it is no better here.
2384
+ *
2385
+ * `meta` is an own, writable property assigned per instance in the SDK's
2386
+ * constructor (`this.meta = { … }`), and the client this adapter builds is
2387
+ * never shared, so the wrap is bounded to an object this adapter owns for its
2388
+ * whole lifetime. It is not a prototype or global patch.
2389
+ *
2390
+ * ## Response shape — measured, not assumed
2391
+ *
2392
+ * The two client classes' envelopes coincide at the top level, which is what
2393
+ * makes `readSaveAdvisories` reusable unchanged across both. `SaveMetaItem-
2394
+ * ResponseSchema` puts `advisories` at the body's top level next to
2395
+ * `success` / `version` / `seq` / `state`, and the SDK's `unwrapResponse`
2396
+ * strips its `{ success, data }` envelope only when the body actually HAS a
2397
+ * `data` key — this body does not, so it is returned verbatim. So the same
2398
+ * reader that `MetadataClient.save` uses reads this response correctly, and
2399
+ * the pins in `onSaveAdvisory.test.ts` drive a real SDK client through a fake
2400
+ * `fetch` rather than stubbing `meta`, so that continues to be measured.
2401
+ *
2402
+ * ## Draft-door honesty (D1)
2403
+ *
2404
+ * Drafts are NEVER gated: the framework returns at its D1 early-return
2405
+ * (`if (args.state !== 'active') return null`) before running a rule, so a
2406
+ * draft save produces no findings to withhold. This client class has no draft
2407
+ * door at all to worry about — the SDK's `saveItem(type, name, item)` takes no
2408
+ * mode and always writes the active door, which is exactly why the gate DOES
2409
+ * run for its callers. `mode` on the emitted event is therefore derived from
2410
+ * the response's own `state` rather than from a request-side flag that does
2411
+ * not exist here: `'draft'` when the server says the row landed as a draft,
2412
+ * `'publish'` otherwise. That keeps the event truthful about which door it
2413
+ * came through instead of hard-coding one.
2414
+ */
2415
+ installSaveAdvisoryInterceptor() {
2416
+ const meta = this.client.meta;
2417
+ const original = meta.saveItem.bind(meta);
2418
+ meta.saveItem = async (type, name, item) => {
2419
+ const result = await original(type, name, item);
2420
+ try {
2421
+ const advisories = readSaveAdvisories(result);
2422
+ if (advisories.length > 0) {
2423
+ this.emitSaveAdvisory({
2424
+ type,
2425
+ name,
2426
+ mode: result?.state === "draft" ? "draft" : "publish",
2427
+ advisories
2428
+ });
2429
+ }
2430
+ } catch (err) {
2431
+ console.warn("ObjectStackAdapter: save-advisory read error", err);
2432
+ }
2433
+ return result;
2434
+ };
2435
+ }
2215
2436
  async create(resource, data) {
2216
2437
  await this.connect();
2217
2438
  try {
@@ -2271,10 +2492,10 @@ var ObjectStackAdapter = class {
2271
2492
  String(id),
2272
2493
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
2273
2494
  );
2274
- if (result.deleted) {
2495
+ if (result.success) {
2275
2496
  this.emitMutation({ type: "delete", resource, id });
2276
2497
  }
2277
- return result.deleted;
2498
+ return result.success;
2278
2499
  } catch (err) {
2279
2500
  throw normaliseClientError(err);
2280
2501
  }
@@ -2817,6 +3038,8 @@ var ObjectStackAdapter = class {
2817
3038
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
2818
3039
  const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
2819
3040
  err.status = res.status;
3041
+ err.code = errorBody?.code ?? errorBody?.error?.code;
3042
+ err.httpStatus = res.status;
2820
3043
  throw err;
2821
3044
  }
2822
3045
  const body = await res.json();
@@ -3075,42 +3298,109 @@ var ObjectStackAdapter = class {
3075
3298
  return null;
3076
3299
  }
3077
3300
  }
3301
+ /**
3302
+ * **The one place that knows which cache keys a view-row write invalidates.**
3303
+ *
3304
+ * Two reads are cached under view-shaped keys, and both go stale when any
3305
+ * writer touches a view row for `objectName`:
3306
+ *
3307
+ * | reader | key |
3308
+ * |----------------------------|----------------------------|
3309
+ * | {@link getView} | `view:{object}:{viewName}` |
3310
+ * | {@link listViewOverrides} | `view-overrides:{object}` |
3311
+ *
3312
+ * Every writer routes here rather than restating that pair. The repo has now
3313
+ * paid twice for restatement: objectui#3778 removed five copies of a key
3314
+ * (`views:{object}`) that no reader had ever populated, and objectui#4363
3315
+ * fixed four copies that named only half the live set — and objectui#4373 is
3316
+ * the measured proof that the failure recurs by default, because the console's
3317
+ * real create/publish flow writes these same rows through the ADR-0034
3318
+ * metadata seam and never learned the key list at all. A pin suite can only
3319
+ * guard the writers that exist; a mandatory seam makes the next writer
3320
+ * structurally unable to forget.
3321
+ *
3322
+ * The rule is uniform per WRITE, not per branch: a write to a view row drops
3323
+ * both keys, in that order. Draft-addressed writes over-invalidate on purpose
3324
+ * — both readers enumerate PUBLISHED rows, so a draft write stales neither —
3325
+ * because the costs are not symmetric. An unnecessary invalidation costs one
3326
+ * refetch; a missed one costs up to the cache's 5-minute TTL of stale
3327
+ * overrides, and "which half am I in?" is not a question a future writer
3328
+ * should have to re-answer to stay correct.
3329
+ *
3330
+ * Public because the writers are not all in this class: app-shell's ADR-0034
3331
+ * seam (`runtime-metadata-persistence.ts`) and `MetadataService` write the
3332
+ * same rows through `client.meta` / the `/meta` draft-publish API, and they
3333
+ * reach this adapter as the `dataSource` they were already handed.
3334
+ *
3335
+ * @param objectName - Object the view belongs to (the `{object}` in both keys)
3336
+ * @param viewName - The view's canonical `name` — the identity
3337
+ * {@link getView} reads back by and {@link listViewOverrides} keys its map
3338
+ * with. Callers that hold a qualified `<object>.<key>` name pass it as-is:
3339
+ * it is the row key, not a path to be split.
3340
+ */
3341
+ invalidateViewKeys(objectName, viewName) {
3342
+ this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
3343
+ this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
3344
+ }
3078
3345
  /**
3079
3346
  * Batch-fetch all persisted view overrides for an object.
3080
3347
  *
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.
3348
+ * Per-view runtime overrides (density, column widths, sort, hidden
3349
+ * columns, inlineEdit …) live in the SAME metadata namespace the
3350
+ * write path uses: `type='view'`, `name=<viewId>` (see
3351
+ * {@link updateViewConfig}). Loading them per-view fires N HTTP GETs
3352
+ * that 404 for every view the user never customized — console noise on
3353
+ * every page load. This batch method performs a single
3354
+ * `GET /api/v1/meta/view` (returns `{type, items}`) and narrows the
3355
+ * result to `objectName` client-side, exactly as {@link listViews}
3356
+ * does over the same rows (shared accessor: {@link viewItemObjectName}).
3357
+ *
3358
+ * objectui#3774 — this used to enumerate `GET /api/v1/meta/<objectName>`,
3359
+ * putting the OBJECT name in the metadata TYPE slot. That key space is
3360
+ * disjoint from the one the write path lands in, so the batch map came
3361
+ * back empty for every object, forever, and every saved personalization
3362
+ * read back as "setting didn't save".
3363
+ *
3364
+ * FAILURES REJECT — they are not answered as `{}`. An empty map is an
3365
+ * authoritative "this object has no overrides" (callers may trust it and
3366
+ * skip the per-view reads); a transport/permission failure is "I could
3367
+ * not tell", and reporting the two as the same value is what made
3368
+ * ObjectView's per-view {@link getView} fallback unreachable code. When
3369
+ * we cannot tell, we do not pretend we can. Rejections are not cached
3370
+ * (the cache stores on success only), so a transient failure does not
3371
+ * pin an empty answer for the TTL.
3089
3372
  *
3090
- * Result is cached identically to {@link getView}; saving a view via
3091
- * {@link updateViewConfig} invalidates the cache.
3373
+ * Result is cached identically to {@link getView}, and EVERY view write —
3374
+ * on this adapter and above it — invalidates it through the one seam,
3375
+ * {@link invalidateViewKeys}. For a long time only {@link updateViewConfig}
3376
+ * did (objectui#4363), which left the other three adapter paths stale
3377
+ * for the cache's 5-minute TTL. That gap does not self-heal: the consumer
3378
+ * (`loadViewOverrides`, app-shell `ObjectView`) treats a RESOLVED map as
3379
+ * authoritative and deliberately does not re-probe per view — objectui#3774,
3380
+ * and correct, since re-probing reinstates the 404 flurry the batch read
3381
+ * exists to remove. So a stale map here is served in full, and the per-view
3382
+ * {@link getView} fallback that would have masked it never runs.
3092
3383
  *
3093
3384
  * @param objectName - Object name (e.g. 'lead')
3094
3385
  * @returns Map keyed by view name with the persisted override config
3386
+ * @throws whatever the metadata transport throws — callers that have a
3387
+ * per-view fallback should catch and use it.
3095
3388
  */
3096
3389
  async listViewOverrides(objectName) {
3097
3390
  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
- }
3391
+ const cacheKey = `view-overrides:${objectName}`;
3392
+ return await this.metadataCache.get(cacheKey, async () => {
3393
+ const result = await this.client.meta.getItems("view");
3394
+ const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
3395
+ const out = {};
3396
+ for (const it of items) {
3397
+ if (!it || typeof it !== "object") continue;
3398
+ if (viewItemObjectName(it) !== objectName) continue;
3399
+ const key = it.name;
3400
+ if (typeof key === "string" && key) out[key] = it;
3401
+ }
3402
+ return out;
3403
+ });
3114
3404
  }
3115
3405
  /**
3116
3406
  * Get a view definition for an object.
@@ -3160,10 +3450,7 @@ var ObjectStackAdapter = class {
3160
3450
  viewId,
3161
3451
  merged
3162
3452
  );
3163
- const cacheKey = `view:${objectName}:${viewId}`;
3164
- this.metadataCache.invalidate?.(cacheKey);
3165
- this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
3166
- this.metadataCache.invalidate?.(`views:${objectName}`);
3453
+ this.invalidateViewKeys(objectName, viewId);
3167
3454
  if (result && result.item) return result.item;
3168
3455
  return result ?? void 0;
3169
3456
  }
@@ -3174,8 +3461,10 @@ var ObjectStackAdapter = class {
3174
3461
  * match the view spec shape.
3175
3462
  *
3176
3463
  * 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.
3464
+ * Narrows to one object client-side via {@link viewItemObjectName} —
3465
+ * the metadata index is name-only, not field-typed, so the route has no
3466
+ * `?object=` to push the filter down into. {@link listViewOverrides}
3467
+ * reads the same rows through the same accessor.
3179
3468
  */
3180
3469
  async listViews(objectName, options) {
3181
3470
  await this.connect();
@@ -3192,8 +3481,7 @@ var ObjectStackAdapter = class {
3192
3481
  return items.filter((v) => {
3193
3482
  if (!v) return false;
3194
3483
  const spec = v.list ?? v;
3195
- const obj = spec?.data?.object ?? spec?.object ?? spec?.objectName;
3196
- if (obj !== objectName) return false;
3484
+ if (viewItemObjectName(v) !== objectName) return false;
3197
3485
  const viewKind = v.viewKind ?? spec?.viewKind;
3198
3486
  return !(viewKind && FORM_FAMILY.has(viewKind));
3199
3487
  }).map((v) => {
@@ -3254,6 +3542,10 @@ var ObjectStackAdapter = class {
3254
3542
  *
3255
3543
  * Generates a snake_case name if `spec.name` is not provided by appending
3256
3544
  * a short timestamp suffix to the source-name hint.
3545
+ *
3546
+ * Invalidates through {@link invalidateViewKeys}, like every other writer —
3547
+ * see {@link listViewOverrides} for why the batch map is the one that cannot
3548
+ * heal itself (objectui#4363).
3257
3549
  */
3258
3550
  async createView(objectName, spec) {
3259
3551
  await this.connect();
@@ -3273,48 +3565,184 @@ var ObjectStackAdapter = class {
3273
3565
  data: spec?.data || { provider: "object", object: objectName }
3274
3566
  };
3275
3567
  const result = await this.client.meta.saveItem("view", name, fullSpec);
3276
- this.metadataCache.invalidate?.(`views:${objectName}`);
3568
+ this.invalidateViewKeys(objectName, name);
3277
3569
  if (result && result.item) return result.item;
3278
3570
  return fullSpec;
3279
3571
  }
3280
3572
  /**
3281
3573
  * 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.
3574
+ * document, merges, and writes it back. ADR-0005 overlay rows store the
3575
+ * *full* view document, so partial updates require a read-merge-write cycle.
3576
+ *
3577
+ * **Both halves address the same row (#4139).** A view has two possible
3578
+ * homes and the read must resolve the one the write will target:
3579
+ *
3580
+ * - a pending per-item **draft** (`?state=draft` / `?mode=draft`) — where
3581
+ * ADR-0034 stages every runtime-created view, so a view made from the `+`
3582
+ * tab lives ONLY here until an explicit Publish;
3583
+ * - the **published** overlay (`client.meta.getItem` / `saveItem`).
3584
+ *
3585
+ * The draft is probed FIRST, and a hit is merged and written straight back
3586
+ * as a draft. Two things that ordering buys, both load-bearing:
3587
+ *
3588
+ * 1. A draft-only view is no longer invisible to the read. It used to 404,
3589
+ * and a `catch {}` labelled "treat missing as create-equivalent"
3590
+ * substituted `current = {}` — so a rename merged onto nothing and went
3591
+ * out as a `{label, name, object}` partial the server rejects (422),
3592
+ * while the draft row the UI reads back through `?preview=draft` kept the
3593
+ * old label. The edit was lost with no error surfaced to the user.
3594
+ * 2. A draft is never bypassed. Writing the published row while a draft is
3595
+ * pending would put the edit somewhere the draft shadows — and Publish
3596
+ * would then overwrite it with the pre-edit body, losing the change a
3597
+ * second time, later, where nothing connects it to this call.
3598
+ *
3599
+ * A draft edit stays a draft: `mode: 'draft'` keeps ADR-0037's guarantee
3600
+ * that nothing the preview shows goes live until Publish. Renaming a
3601
+ * *published* view (no draft pending) is unchanged — it writes the
3602
+ * published overlay, as before.
3603
+ *
3604
+ * **Both halves invalidate through {@link invalidateViewKeys}** (objectui#4363,
3605
+ * #4373) — same call, same key set, so the draft half's deliberate
3606
+ * over-invalidation is the seam's uniform rule rather than a per-branch
3607
+ * decision this method has to keep re-making.
3608
+ *
3609
+ * @throws when the view resolves in neither home, or when either read fails
3610
+ * for any other reason (network, permission). Both used to be swallowed
3611
+ * and converted into the bad partial write above; a caller that wants a
3612
+ * view created should call {@link createView}, which is the operation that
3613
+ * actually means "create".
3285
3614
  */
3286
3615
  async updateView(objectName, viewName, partial) {
3287
3616
  await this.connect();
3288
- let current = {};
3617
+ const metaClient = this.metadataClient();
3618
+ const draft = unwrapViewDraft(
3619
+ await metaClient.get("view", viewName, { state: "draft" })
3620
+ );
3621
+ if (draft) {
3622
+ const mergedDraft = mergeViewPatch(draft, partial, viewName, objectName);
3623
+ await metaClient.save("view", viewName, mergedDraft, { mode: "draft" });
3624
+ this.invalidateViewKeys(objectName, viewName);
3625
+ return mergedDraft;
3626
+ }
3627
+ let current;
3289
3628
  try {
3290
3629
  const r = await this.client.meta.getItem("view", viewName);
3291
3630
  current = r && (r.item || r) || {};
3292
3631
  if (current?.list) current = current.list;
3293
- } catch {
3632
+ } catch (err) {
3633
+ if (is404Error(err)) {
3634
+ throw Object.assign(
3635
+ new Error(
3636
+ `updateView: view "${viewName}" not found on object "${objectName}" \u2014 no pending draft and no published overlay. Use createView() to create one.`
3637
+ ),
3638
+ { cause: err }
3639
+ );
3640
+ }
3641
+ throw err;
3294
3642
  }
3295
- const merged = {
3296
- ...current,
3297
- ...partial,
3298
- name: viewName,
3299
- object: current?.object || current?.data?.object || objectName
3300
- };
3643
+ const merged = mergeViewPatch(current, partial, viewName, objectName);
3301
3644
  const result = await this.client.meta.saveItem("view", viewName, merged);
3302
- this.metadataCache.invalidate?.(`views:${objectName}`);
3303
- this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
3645
+ this.invalidateViewKeys(objectName, viewName);
3304
3646
  if (result && result.item) return result.item;
3305
3647
  return merged;
3306
3648
  }
3307
3649
  /**
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`.
3650
+ * Delete an overlay view — from **every home it has**.
3651
+ *
3652
+ * A view has two possible homes, the same two {@link updateView} addresses:
3653
+ * the pending per-item **draft** (`DELETE /meta/view/:name?state=draft`) and
3654
+ * the **published** overlay (`DELETE /meta/view/:name`). This method used to
3655
+ * issue only the second, and the three cases came out like this (#4479):
3656
+ *
3657
+ * | case | before |
3658
+ * |--------------------|-----------------------------------------------------|
3659
+ * | draft-only view | BUG — the published delete answered `reset:false` / |
3660
+ * | | "nothing to delete", the draft survived, and the tab |
3661
+ * | | was still there after reload |
3662
+ * | published-only | correct — `reset:true`, tab gone |
3663
+ * | published + draft | ACCIDENTALLY correct — the published row went, so |
3664
+ * | | the tab went; the orphan draft stayed behind |
3665
+ *
3666
+ * **Why this is not the mechanical mirror of #4139.** `updateView` probes
3667
+ * the draft first and writes back to whichever home the read resolved, and
3668
+ * that is right for an update in all three cases. Copying it here would be
3669
+ * wrong in the third: `persistRuntimeMetadata` (app-shell) stages EVERY
3670
+ * runtime edit as a draft, so "publish a view, then edit it" routinely
3671
+ * produces a pair — and a draft-first-only delete on a pair discards the
3672
+ * draft and leaves the published row serving the view. That is not Delete
3673
+ * view, it is **Discard draft**, a deliberately different operation that
3674
+ * already exists (`discardRuntimeDraft`, documented as "the published
3675
+ * overlay is untouched"). The clean statement of the asymmetry: for an
3676
+ * update, one home is the right home; for a delete, "remove this view" is
3677
+ * satisfied only when NO home is left serving it.
3678
+ *
3679
+ * So both homes are deleted, **draft first**. The order is load-bearing on
3680
+ * the failure path: a fault between the two calls leaves the PUBLISHED
3681
+ * overlay intact, so the view is still served and the operation is cleanly
3682
+ * retryable. The reverse order would strand a draft-only view — which is
3683
+ * precisely the bug shape above.
3684
+ *
3685
+ * **Two blind calls, no probe.** The framework's `deleteMetaItem` answers a
3686
+ * missing home with a **200** carrying `reset:false` (`"No pending draft
3687
+ * for view/x."` / `"No view 'x' found — nothing to delete."`), never a 404,
3688
+ * so there is nothing for a probe to protect against. `updateView` needs its
3689
+ * probe for a different reason — its read must resolve the row the merge
3690
+ * writes back to — and that reason has no counterpart for a delete.
3691
+ *
3692
+ * **One transport, one error contract.** Both halves go through
3693
+ * {@link MetadataClient} (`reset`), the transport that can express the
3694
+ * `?state=` qualifier and the one `updateView`'s draft half already uses.
3695
+ * The published half used to go through `client.meta.deleteItem`; measured,
3696
+ * that issues the byte-identical request (`DELETE
3697
+ * {baseUrl}/api/v1/meta/view/:name`, no environment scoping is configured on
3698
+ * this adapter), so routing it here costs no addressing change and buys a
3699
+ * single `MetadataError` shape across both calls instead of two.
3700
+ *
3701
+ * @returns `deleted` is true only when no home is left serving the view AND
3702
+ * at least one actually held a row. A view that existed in neither home
3703
+ * still answers `false`, unchanged. The per-home outcomes are additive:
3704
+ * a partial result is observable rather than rounded up to `true`.
3705
+ * @throws when either delete fails, matching {@link updateView}'s
3706
+ * convention of surfacing the fault rather than degrading. A failure of
3707
+ * the PUBLISHED half after the draft was discarded carries the partial
3708
+ * state on the error's `outcome` — "draft gone, overlay left" is exactly
3709
+ * what the old `{ deleted: boolean }` could not express.
3710
+ *
3711
+ * Invalidates through {@link invalidateViewKeys}: the deleted row leaves the
3712
+ * batch override map too, and a ghost entry there is what the object page
3713
+ * would keep applying (objectui#4363). Fired in a `finally`, so it happens
3714
+ * once per call on EVERY outcome including the throw — after a half-failure
3715
+ * the draft row really is gone, and #4363's asymmetry decides it: an
3716
+ * unnecessary invalidation costs one refetch, a missed one costs the cache's
3717
+ * full 5-minute TTL of stale overrides.
3311
3718
  */
3312
3719
  async deleteView(objectName, viewName) {
3313
3720
  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) };
3721
+ const metaClient = this.metadataClient();
3722
+ try {
3723
+ const draft = readViewDeleteReceipt(
3724
+ await metaClient.reset("view", viewName, { state: "draft" })
3725
+ );
3726
+ let published;
3727
+ try {
3728
+ published = readViewDeleteReceipt(await metaClient.reset("view", viewName));
3729
+ } catch (err) {
3730
+ const outcome = {
3731
+ deleted: false,
3732
+ draft,
3733
+ published: { removed: false }
3734
+ };
3735
+ throw Object.assign(
3736
+ new Error(
3737
+ `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.`
3738
+ ),
3739
+ { cause: err, outcome }
3740
+ );
3741
+ }
3742
+ return { deleted: draft.removed || published.removed, draft, published };
3743
+ } finally {
3744
+ this.invalidateViewKeys(objectName, viewName);
3745
+ }
3318
3746
  }
3319
3747
  /**
3320
3748
  * Get an application definition by name or ID.
@@ -3338,6 +3766,51 @@ var ObjectStackAdapter = class {
3338
3766
  return null;
3339
3767
  }
3340
3768
  }
3769
+ /**
3770
+ * Ask the by-name meta app route WHY an app is not in this session's app list
3771
+ * (objectui#4252).
3772
+ *
3773
+ * `GET /api/v1/meta/apps` is filtered per session server-side
3774
+ * (`filterAppForUser`), so an app withheld by `requiredPermissions` and an app
3775
+ * that does not exist are byte-identical there: both are simply absent. A
3776
+ * console reading only that list has one fact and two conditions, and it
3777
+ * renders its copy for the wrong one — "it may still be publishing" over a
3778
+ * permanent authorization decision, which cost a downstream acceptance round
3779
+ * two test batches spent chasing a platform defect that was a missing
3780
+ * permission-set binding.
3781
+ *
3782
+ * The maintainer ruling (2026-08-12) put the answer on the BY-NAME route
3783
+ * rather than in the list, so the enumeration surface is not widened past what
3784
+ * a by-name probe already implies (objectstack#8013 / PR #8135): an app that
3785
+ * exists and whose `requiredPermissions` the session lacks answers `403` with
3786
+ * `PERMISSION_DENIED` in the declared envelope, and absence — a nonexistent
3787
+ * name, an unpublished app, an app gated by an absent optional service —
3788
+ * keeps answering `404 RESOURCE_NOT_FOUND`.
3789
+ *
3790
+ * ## Why this is a separate method and not a flavour of {@link getApp}
3791
+ *
3792
+ * - `getApp` degrades EVERY failure to `null`, which is exactly the
3793
+ * conflation this exists to undo; changing it would silently re-point its
3794
+ * own callers' fallback-to-static-config path.
3795
+ * - `getApp` memoises in `metadataCache`. A verdict about the CALLER must not
3796
+ * be cached beside a document about the APP — one grant, and a cached
3797
+ * denial outlives the session it described.
3798
+ *
3799
+ * Nothing here throws: a probe that cannot reach an answer returns `unknown`
3800
+ * and the caller keeps whatever it was already showing. Only the measured
3801
+ * `code` produces `denied` — never a status, never a message (objectui#4408).
3802
+ *
3803
+ * @param appName - the app name as it appears in the URL segment
3804
+ */
3805
+ async probeAppAccess(appName) {
3806
+ if (!appName) return "unknown";
3807
+ try {
3808
+ await this.client.meta.getItem("app", appName);
3809
+ return "granted";
3810
+ } catch (err) {
3811
+ return isAppPermissionDeniedError(err) ? "denied" : "unknown";
3812
+ }
3813
+ }
3341
3814
  /**
3342
3815
  * Get a page definition from ObjectStack.
3343
3816
  * Uses the metadata API to fetch page layouts.
@@ -3490,7 +3963,17 @@ var ObjectStackAdapter = class {
3490
3963
  * silently returning wrong numbers.
3491
3964
  *
3492
3965
  * @param dataset - An inline dataset definition (draft) OR a saved dataset name.
3493
- * @param selection - Dimension/measure names to project + runtime directives.
3966
+ * @param selection - The spec's {@link DatasetSelection} — dimension/measure
3967
+ * names to project plus runtime directives. This parameter IS the spec type
3968
+ * by reference, never a local restatement of it (objectui#3613): a hand
3969
+ * copy of a contract is a second dialect of it, and the copy this replaced
3970
+ * had already drifted three ways from `@objectstack/spec` — it required
3971
+ * `compareTo.dimension` (optional since objectstack#5011, and resolved by
3972
+ * the EXECUTOR, so requiring it pushed callers into exactly the
3973
+ * consumer-side dimension guess AGENTS.md #0.1 forbids), it widened
3974
+ * `timeDimensions` to `unknown[]` and `runtimeFilter` to
3975
+ * `Record<string, unknown>`, and it had never grown `dateGranularity` at
3976
+ * all. Pinned in `queryDataset.test.ts`.
3494
3977
  */
3495
3978
  async queryDataset(dataset, selection) {
3496
3979
  await this.connect();
@@ -3525,8 +4008,9 @@ var ObjectStackAdapter = class {
3525
4008
  const object = typeof data?.object === "string" ? data.object : void 0;
3526
4009
  const dimensionFields = data?.dimensionFields && typeof data.dimensionFields === "object" ? data.dimensionFields : void 0;
3527
4010
  const drillRawRows = Array.isArray(data?.drillRawRows) ? data.drillRawRows : void 0;
4011
+ const drillRanges = Array.isArray(data?.drillRanges) ? data.drillRanges : void 0;
3528
4012
  const totals = Array.isArray(data?.totals) ? data.totals : void 0;
3529
- return { rows, fields, object, dimensionFields, drillRawRows, totals };
4013
+ return { rows, fields, object, dimensionFields, drillRawRows, drillRanges, totals };
3530
4014
  }
3531
4015
  /** Client-side aggregation fallback */
3532
4016
  /**
@@ -3771,11 +4255,12 @@ function createObjectStackAdapter(config) {
3771
4255
  }
3772
4256
  // Annotate the CommonJS export names for ESM import in node:
3773
4257
  0 && (module.exports = {
4258
+ API_ACCESS_DENIED_CODES,
4259
+ APP_PERMISSION_DENIED_CODE,
3774
4260
  AnalyticsNotInstalledError,
3775
4261
  AnalyticsQueryRejectedError,
3776
4262
  AuthenticationError,
3777
4263
  BulkOperationError,
3778
- CloudOperations,
3779
4264
  ConcurrentUpdateError,
3780
4265
  ConnectionError,
3781
4266
  DataApiValidationError,
@@ -3794,17 +4279,23 @@ function createObjectStackAdapter(config) {
3794
4279
  createErrorFromResponse,
3795
4280
  createObjectStackAdapter,
3796
4281
  createObjectStackUserStateAdapter,
4282
+ createQuietHttpLogger,
4283
+ formatHttpFailureMessage,
3797
4284
  generateContractManifest,
3798
4285
  getSharedDiscovery,
3799
4286
  is404Error,
3800
4287
  isAnalyticsNotInstalledError,
4288
+ isApiAccessDeniedError,
4289
+ isAppPermissionDeniedError,
3801
4290
  isConcurrentUpdateError,
3802
4291
  isErrorType,
3803
4292
  isMalformedFilterError,
3804
4293
  isObjectStackError,
3805
4294
  normaliseClientError,
4295
+ readSaveAdvisories,
3806
4296
  readTransactionalBatchCapability,
3807
4297
  serializeOrderBy,
3808
4298
  snapToGrid,
3809
- validatePluginContract
4299
+ validatePluginContract,
4300
+ viewItemObjectName
3810
4301
  });