@object-ui/data-objectstack 17.2.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,362 +5,17 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
5
5
  // src/index.ts
6
6
  import { ObjectStackClient } from "@objectstack/client";
7
7
 
8
- // ../types/src/index.ts
9
- import {
10
- defineStack,
11
- ObjectStackSchema,
12
- ObjectStackDefinitionSchema
13
- } from "@objectstack/spec";
14
-
15
- // ../types/src/error-code.ts
16
- function errorCodeIs(err, code) {
17
- const raw = err?.code;
18
- if (typeof raw !== "string" || raw.length === 0) return false;
19
- return raw.toUpperCase() === code.toUpperCase();
20
- }
21
- function errorCodeIsAnyOf(err, codes) {
22
- return codes.some((c) => errorCodeIs(err, c));
23
- }
24
-
25
- // ../core/src/utils/filter-converter.ts
26
- var FilterOperatorError = class extends Error {
27
- constructor(message) {
28
- super(message);
29
- __publicField(this, "code", "INVALID_FILTER");
30
- __publicField(this, "httpStatus", 400);
31
- this.name = "FilterOperatorError";
32
- }
33
- };
34
- function convertOperatorToAST(operator) {
35
- const operatorMap = {
36
- "$eq": "=",
37
- "$ne": "!=",
38
- "$gt": ">",
39
- "$gte": ">=",
40
- "$lt": "<",
41
- "$lte": "<=",
42
- "$in": "in",
43
- "$nin": "nin",
44
- "$notin": "nin",
45
- "$between": "between",
46
- "$contains": "contains",
47
- "$notContains": "notcontains",
48
- "$notcontains": "notcontains",
49
- "$startsWith": "startswith",
50
- "$startswith": "startswith",
51
- "$endsWith": "endswith",
52
- "$endswith": "endswith"
53
- };
54
- return operatorMap[operator] || null;
55
- }
56
- function convertFiltersToAST(filter) {
57
- const conditions = [];
58
- for (const [field, value] of Object.entries(filter)) {
59
- if (value === null || value === void 0) continue;
60
- if (typeof value === "object" && !Array.isArray(value)) {
61
- for (const [operator, operatorValue] of Object.entries(value)) {
62
- if (operator === "$regex") {
63
- throw new FilterOperatorError(
64
- `[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.`
65
- );
66
- }
67
- if (operator === "$null") {
68
- conditions.push([field, operatorValue ? "is_null" : "is_not_null", true]);
69
- continue;
70
- }
71
- if (operator === "$exists") {
72
- conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
73
- continue;
74
- }
75
- const astOperator = convertOperatorToAST(operator);
76
- if (astOperator) {
77
- conditions.push([field, astOperator, operatorValue]);
78
- } else {
79
- throw new FilterOperatorError(
80
- `[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.`
81
- );
82
- }
83
- }
84
- } else {
85
- conditions.push([field, "=", value]);
86
- }
87
- }
88
- if (conditions.length === 0) {
89
- return filter;
90
- }
91
- if (conditions.length === 1) {
92
- return conditions[0];
93
- }
94
- return ["and", ...conditions];
95
- }
96
-
97
- // ../core/src/adapters/batchTransaction.ts
98
- function idOf(rec) {
99
- if (rec == null) return void 0;
100
- return rec.id ?? rec._id ?? rec.recordId;
101
- }
102
- function resolveRefs(data, results) {
103
- const out = {};
104
- for (const [key, value] of Object.entries(data)) {
105
- if (value && typeof value === "object" && !Array.isArray(value) && typeof value.$ref === "number") {
106
- const refIndex = value.$ref;
107
- const id = idOf(results[refIndex]);
108
- if (id === void 0) {
109
- throw new Error(
110
- `batchTransaction: field "${key}" references operation ${refIndex}, which has not produced an id (forward or invalid $ref)`
111
- );
112
- }
113
- out[key] = id;
114
- } else {
115
- out[key] = value;
116
- }
117
- }
118
- return out;
119
- }
120
- async function emulateBatchTransaction(dataSource, operations) {
121
- const results = [];
122
- const created = [];
123
- try {
124
- for (const op of operations) {
125
- const action = op.action ?? "create";
126
- if (action === "create") {
127
- const record = await dataSource.create(
128
- op.object,
129
- resolveRefs(op.data ?? {}, results)
130
- );
131
- results.push(record);
132
- const id = idOf(record);
133
- if (id) created.push({ object: op.object, id });
134
- } else if (action === "update") {
135
- if (op.id == null) {
136
- throw new Error(`batchTransaction: update on "${op.object}" requires an id`);
137
- }
138
- results.push(
139
- await dataSource.update(
140
- op.object,
141
- op.id,
142
- resolveRefs(op.data ?? {}, results)
143
- )
144
- );
145
- } else {
146
- if (op.id == null) {
147
- throw new Error(`batchTransaction: delete on "${op.object}" requires an id`);
148
- }
149
- results.push(await dataSource.delete(op.object, op.id));
150
- }
151
- }
152
- return { results };
153
- } catch (err) {
154
- if (created.length > 0) {
155
- await Promise.allSettled(
156
- [...created].reverse().map((c) => dataSource.delete(c.object, c.id))
157
- );
158
- }
159
- throw err;
160
- }
161
- }
162
-
163
- // ../core/src/utils/reference-keys.ts
164
- function normalizeFieldReferenceKeys(fieldDef) {
165
- if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
166
- const f = fieldDef;
167
- const target = f.reference_to ?? f.reference ?? f.referenceTo;
168
- if (target == null || target === "") return fieldDef;
169
- if (f.reference_to === void 0) f.reference_to = target;
170
- if (f.reference === void 0) f.reference = target;
171
- return fieldDef;
172
- }
173
- function normalizeSchemaReferenceKeys(schema) {
174
- const fields = schema && typeof schema === "object" ? schema.fields : null;
175
- if (!fields || typeof fields !== "object") return schema;
176
- const defs = Array.isArray(fields) ? fields : Object.values(fields);
177
- for (const def of defs) normalizeFieldReferenceKeys(def);
178
- return schema;
179
- }
180
-
181
- // src/cache/MetadataCache.ts
182
- var MetadataCache = class {
183
- /**
184
- * Create a new MetadataCache instance
185
- *
186
- * @param options - Configuration options
187
- * @param options.maxSize - Maximum number of entries (default: 100)
188
- * @param options.ttl - Time to live in milliseconds (default: 5 minutes)
189
- */
190
- constructor(options = {}) {
191
- __publicField(this, "cache");
192
- __publicField(this, "inflight");
193
- __publicField(this, "maxSize");
194
- __publicField(this, "ttl");
195
- __publicField(this, "stats");
196
- this.cache = /* @__PURE__ */ new Map();
197
- this.inflight = /* @__PURE__ */ new Map();
198
- this.maxSize = options.maxSize || 100;
199
- this.ttl = options.ttl || 5 * 60 * 1e3;
200
- this.stats = {
201
- hits: 0,
202
- misses: 0,
203
- evictions: 0,
204
- coalesced: 0
205
- };
206
- }
207
- /**
208
- * Get a value from cache or fetch it using the provided fetcher function
209
- *
210
- * @param key - Cache key
211
- * @param fetcher - Async function to fetch data if not in cache
212
- * @returns Promise resolving to the cached or fetched data
213
- */
214
- async get(key, fetcher) {
215
- const now = Date.now();
216
- const cached = this.cache.get(key);
217
- if (cached) {
218
- const age = now - cached.timestamp;
219
- if (age < this.ttl) {
220
- cached.accessCount++;
221
- cached.lastAccessed = now;
222
- this.stats.hits++;
223
- this.cache.delete(key);
224
- this.cache.set(key, cached);
225
- return cached.data;
226
- } else {
227
- this.cache.delete(key);
228
- }
229
- }
230
- const existing = this.inflight.get(key);
231
- if (existing) {
232
- this.stats.coalesced++;
233
- return existing;
234
- }
235
- this.stats.misses++;
236
- const promise = (async () => {
237
- try {
238
- const data = await fetcher();
239
- this.set(key, data);
240
- return data;
241
- } finally {
242
- this.inflight.delete(key);
243
- }
244
- })();
245
- this.inflight.set(key, promise);
246
- return promise;
247
- }
248
- /**
249
- * Prime the cache with a pre-fetched value. Useful when a bulk endpoint
250
- * (e.g. list of all object schemas) returns data that would otherwise
251
- * be fetched again per item.
252
- */
253
- prime(key, data) {
254
- this.set(key, data);
255
- }
256
- /**
257
- * Set a value in the cache
258
- *
259
- * @param key - Cache key
260
- * @param data - Data to cache
261
- */
262
- set(key, data) {
263
- const now = Date.now();
264
- if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
265
- this.evictLRU();
266
- }
267
- this.cache.set(key, {
268
- data,
269
- timestamp: now,
270
- accessCount: 1,
271
- lastAccessed: now
272
- });
273
- }
274
- /**
275
- * Evict the least recently used entry
276
- */
277
- evictLRU() {
278
- const firstKey = this.cache.keys().next().value;
279
- if (firstKey !== void 0) {
280
- this.cache.delete(firstKey);
281
- this.stats.evictions++;
282
- }
283
- }
284
- /**
285
- * Invalidate a specific cache entry or all entries
286
- *
287
- * @param key - Optional key to invalidate. If omitted, invalidates all entries
288
- */
289
- invalidate(key) {
290
- if (key) {
291
- this.cache.delete(key);
292
- } else {
293
- this.cache.clear();
294
- }
295
- }
296
- /**
297
- * Clear all cache entries and reset statistics
298
- */
299
- clear() {
300
- this.cache.clear();
301
- this.inflight.clear();
302
- this.stats = {
303
- hits: 0,
304
- misses: 0,
305
- evictions: 0,
306
- coalesced: 0
307
- };
308
- }
309
- /**
310
- * Get cache statistics
311
- *
312
- * @returns Cache statistics including hit rate
313
- */
314
- getStats() {
315
- const total = this.stats.hits + this.stats.misses;
316
- const hitRate = total > 0 ? this.stats.hits / total : 0;
317
- return {
318
- size: this.cache.size,
319
- maxSize: this.maxSize,
320
- hits: this.stats.hits,
321
- misses: this.stats.misses,
322
- evictions: this.stats.evictions,
323
- coalesced: this.stats.coalesced,
324
- hitRate
325
- };
326
- }
327
- /**
328
- * Get a cached value synchronously without triggering a fetch.
329
- * Returns undefined if not in cache or expired.
330
- */
331
- getCachedSync(key) {
332
- const entry = this.cache.get(key);
333
- if (!entry) return void 0;
334
- if (this.ttl > 0 && Date.now() - entry.timestamp > this.ttl) {
335
- this.cache.delete(key);
336
- return void 0;
337
- }
338
- this.cache.delete(key);
339
- this.cache.set(key, entry);
340
- this.stats.hits++;
341
- return entry.data;
342
- }
343
- /**
344
- * Check if a key exists in the cache (and is not expired)
345
- *
346
- * @param key - Cache key to check
347
- * @returns true if the key exists and is not expired
348
- */
349
- has(key) {
350
- const cached = this.cache.get(key);
351
- if (!cached) {
352
- return false;
353
- }
354
- const age = Date.now() - cached.timestamp;
355
- if (age >= this.ttl) {
356
- this.cache.delete(key);
357
- return false;
358
- }
359
- return true;
360
- }
361
- };
362
-
363
8
  // src/metadata-client.ts
9
+ function readSaveAdvisories(body) {
10
+ if (!body || typeof body !== "object") return [];
11
+ const raw = body.advisories;
12
+ if (!Array.isArray(raw)) return [];
13
+ return raw.filter((f) => {
14
+ if (!f || typeof f !== "object") return false;
15
+ const c = f;
16
+ 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";
17
+ });
18
+ }
364
19
  var META_PREFIX = "/meta";
365
20
  var API_PREFIX = "/api/v1";
366
21
  function buildBase(config) {
@@ -393,6 +48,11 @@ async function parseError(res) {
393
48
  if (code != null) err.code = String(code);
394
49
  return err;
395
50
  }
51
+ function isMetaItemEnvelope(value) {
52
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
53
+ const env = value;
54
+ return typeof env.type === "string" && typeof env.name === "string" && "item" in env;
55
+ }
396
56
  var MetadataClient = class _MetadataClient {
397
57
  constructor(config) {
398
58
  __publicField(this, "base");
@@ -400,10 +60,13 @@ var MetadataClient = class _MetadataClient {
400
60
  __publicField(this, "headers");
401
61
  /** ADR-0037: when true, reads render the draft-overlaid world. */
402
62
  __publicField(this, "previewDrafts");
63
+ /** #4133 — sink for post-save advisory findings; see the config field. */
64
+ __publicField(this, "onSaveAdvisory");
403
65
  this.base = buildBase(config);
404
66
  this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
405
67
  this.headers = { Accept: "application/json", ...config.headers ?? {} };
406
68
  this.previewDrafts = config.previewDrafts === true;
69
+ this.onSaveAdvisory = config.onSaveAdvisory;
407
70
  }
408
71
  /** Update the client's environment scope at runtime. */
409
72
  withEnvironment(environmentId) {
@@ -412,7 +75,12 @@ var MetadataClient = class _MetadataClient {
412
75
  environmentId,
413
76
  fetch: this.fetchImpl,
414
77
  headers: this.headers,
415
- previewDrafts: this.previewDrafts
78
+ previewDrafts: this.previewDrafts,
79
+ // #4133: the advisory sink must survive the clone. `useMetadataClient`
80
+ // routes EVERY environment-scoped console client through here, so
81
+ // dropping it would silently disable the channel for exactly the
82
+ // multi-environment tenants the gate is loudest for.
83
+ ...this.onSaveAdvisory ? { onSaveAdvisory: this.onSaveAdvisory } : {}
416
84
  });
417
85
  }
418
86
  /**
@@ -428,7 +96,11 @@ var MetadataClient = class _MetadataClient {
428
96
  ...envMatch ? { environmentId: decodeURIComponent(envMatch[1]) } : {},
429
97
  fetch: this.fetchImpl,
430
98
  headers: this.headers,
431
- previewDrafts
99
+ previewDrafts,
100
+ // #4133: carried for the same reason as in `withEnvironment` — preview
101
+ // mode swaps the whole renderer tree's client, and writes are unaffected
102
+ // by ADR-0037, so the save door behind this clone still advises.
103
+ ...this.onSaveAdvisory ? { onSaveAdvisory: this.onSaveAdvisory } : {}
432
104
  });
433
105
  }
434
106
  /** List all registered metadata types (returns the registry rows). */
@@ -481,290 +153,700 @@ var MetadataClient = class _MetadataClient {
481
153
  return data?.drafts ?? data?.data?.drafts ?? [];
482
154
  }
483
155
  /**
484
- * Get a single metadata item. Returns the unwrapped item content
485
- * (matching the framework REST handler which calls `res.json(item)`).
486
- * Returns `null` on 404 to keep the call-site ergonomic.
156
+ * Fetch a single metadata item and return the response body EXACTLY as the
157
+ * server sent it — envelope and all.
158
+ *
159
+ * The one transport both {@link get} (which unwraps) and {@link getDraft}
160
+ * (which does not) sit on, so the two can differ in what they hand back
161
+ * without differing in how they ask. Private on purpose: the envelope is a
162
+ * wire detail, and the two published contracts above are the supported ways
163
+ * to read an item.
487
164
  */
488
- async get(type, name, options = {}) {
165
+ async readItemResponse(type, name, options = {}) {
489
166
  const params = [];
490
167
  if (options.state === "draft") params.push("state=draft");
491
168
  else if (this.previewDrafts) params.push("preview=draft");
492
169
  if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
493
170
  const qs = params.length ? `?${params.join("&")}` : "";
494
171
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
495
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
496
- if (res.status === 404) return null;
172
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
173
+ if (res.status === 404) return null;
174
+ if (!res.ok) throw await parseError(res);
175
+ return await res.json();
176
+ }
177
+ /**
178
+ * Get a single metadata item. Returns the unwrapped item CONTENT — the
179
+ * metadata document itself, so `obj.fields` / `obj.label` are reachable
180
+ * directly.
181
+ *
182
+ * The framework answers `GET /meta/:type/:name` with the spec-declared
183
+ * envelope `{ type, name, item, …protection fields }`
184
+ * (`GetMetaItemResponseSchema`; objectstack#5563 collapsed this read to that
185
+ * ONE shape), so the envelope is unwrapped here — at the client boundary,
186
+ * once — rather than by each caller.
187
+ *
188
+ * **This method used to return the envelope while promising the body**
189
+ * (objectui#4271). Nothing detected the disagreement, because the test
190
+ * doubles across the repo were written against this docblock: every consumer
191
+ * reading `obj.fields` got `undefined` in production and a field list in
192
+ * unit tests. The visible cost was the entire field half of the permission
193
+ * matrix reporting "No fields registered for this object." for every object,
194
+ * plus dead RLS CEL autocomplete, an inert report drill-down and a designer
195
+ * that saved the envelope back over the object body.
196
+ *
197
+ * A response that is NOT the envelope (an older server answering the bare
198
+ * document, or a body of its own shape) passes through untouched, and `null`
199
+ * still comes back on 404 to keep the call site ergonomic.
200
+ */
201
+ async get(type, name, options = {}) {
202
+ const resp = await this.readItemResponse(type, name, options);
203
+ if (isMetaItemEnvelope(resp)) return resp.item ?? null;
204
+ return resp;
205
+ }
206
+ /**
207
+ * Read the pending draft body for an item (`?state=draft`). Returns
208
+ * `null` when there is no draft pending. Draft reads do NOT fall
209
+ * back to the published overlay or the artifact registry — a `null`
210
+ * unambiguously means "nothing to publish".
211
+ *
212
+ * Note: this method hands back the `{ type, name, item }` envelope the
213
+ * framework sends, NOT the body — callers read `.item`. That asymmetry with
214
+ * {@link get} is deliberate and long-standing (the draft envelope's identity
215
+ * and protection carriers are part of what a draft reader inspects), so it
216
+ * is preserved by reading the transport directly instead of going through
217
+ * `get()`'s unwrap. `unwrapDraftBody` (app-shell) and `unwrapViewDraft`
218
+ * (this package) are the shared helpers for taking the body out.
219
+ */
220
+ async getDraft(type, name, options = {}) {
221
+ return this.readItemResponse(type, name, {
222
+ state: "draft",
223
+ ...options.packageId ? { packageId: options.packageId } : {}
224
+ });
225
+ }
226
+ /**
227
+ * Save (PUT) a metadata item. The framework accepts both the bare
228
+ * item payload and the `{ item: ... }` / `{ metadata: ... }`
229
+ * envelopes; we send bare for consistency. Pass `mode: 'draft'` to
230
+ * stage the change without publishing (Studio's "Save" button).
231
+ */
232
+ async save(type, name, item, options = {}) {
233
+ if (!name || !String(name).trim()) {
234
+ throw new Error(
235
+ `MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
236
+ );
237
+ }
238
+ const params = [];
239
+ if (options.force) params.push("force=true");
240
+ if (options.mode === "draft") params.push("mode=draft");
241
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
242
+ const qs = params.length ? `?${params.join("&")}` : "";
243
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
244
+ const headers = {
245
+ ...this.headers,
246
+ "Content-Type": "application/json"
247
+ };
248
+ if (options.ifMatch) headers["If-Match"] = options.ifMatch;
249
+ if (options.actor) headers["X-Actor"] = options.actor;
250
+ const res = await this.fetchImpl(url, {
251
+ method: "PUT",
252
+ headers,
253
+ body: JSON.stringify(item)
254
+ });
255
+ if (!res.ok) throw await parseError(res);
256
+ const body = await res.json();
257
+ if (this.onSaveAdvisory) {
258
+ try {
259
+ const advisories = readSaveAdvisories(body);
260
+ if (advisories.length > 0) {
261
+ this.onSaveAdvisory({
262
+ type,
263
+ name,
264
+ mode: options.mode === "draft" ? "draft" : "publish",
265
+ advisories
266
+ });
267
+ }
268
+ } catch {
269
+ }
270
+ }
271
+ return body;
272
+ }
273
+ /**
274
+ * Publish a single pending draft BY REFERENCE — promotes the draft row for
275
+ * `(type, name)` into the active overlay and drops the draft. Works for ANY
276
+ * draft, including ones with no `packageId` binding (which the package-scoped
277
+ * `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
278
+ * exact set returned by {@link listDrafts} without needing a package.
279
+ *
280
+ * Returns the server result. For `seed` drafts the protocol also materializes
281
+ * the rows and reports under `seedApplied` — a data problem never fails the
282
+ * publish, so callers should check `seedApplied?.success` and warn the user
283
+ * rather than assume the data went live.
284
+ */
285
+ async publishDraft(type, name) {
286
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
287
+ const res = await this.fetchImpl(url, {
288
+ method: "POST",
289
+ headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
290
+ body: "{}"
291
+ });
292
+ if (!res.ok) throw await parseError(res);
293
+ const body = await res.json().catch(() => ({}));
294
+ const inner = body?.data && typeof body.data === "object" ? body.data : body;
295
+ return inner;
296
+ }
297
+ /**
298
+ * Get the 3-state layered view of a metadata item (Phase 3a). Returns
299
+ * `code` (the artifact / fallback default), `overlay` (the saved
300
+ * customisation, if any), and `effective` (what the runtime sees).
301
+ */
302
+ async layered(type, name, options = {}) {
303
+ const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : "";
304
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
305
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
306
+ if (res.status === 404) {
307
+ return { code: null, overlay: null, overlayScope: null, effective: null };
308
+ }
309
+ if (!res.ok) throw await parseError(res);
310
+ const body = await res.json();
311
+ const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
312
+ if (!hasEnvelope) {
313
+ return {
314
+ code: null,
315
+ overlay: null,
316
+ overlayScope: null,
317
+ effective: body
318
+ };
319
+ }
320
+ return {
321
+ code: body.code ?? null,
322
+ overlay: body.overlay ?? null,
323
+ overlayScope: body.overlayScope ?? null,
324
+ effective: body.effective ?? null,
325
+ ...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
326
+ // ADR-0010 Phase 4 — pass through the metadata-protection envelope so
327
+ // the editor can render lock affordances without a second round trip.
328
+ ...body.lock !== void 0 ? { lock: body.lock } : {},
329
+ ...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
330
+ ...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
331
+ ...body.lockDocsUrl !== void 0 ? { lockDocsUrl: body.lockDocsUrl } : {},
332
+ ...body.provenance !== void 0 ? { provenance: body.provenance } : {},
333
+ ...body.packageId !== void 0 ? { packageId: body.packageId } : {},
334
+ ...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
335
+ ...body.editable !== void 0 ? { editable: body.editable } : {},
336
+ ...body.deletable !== void 0 ? { deletable: body.deletable } : {},
337
+ ...body.resettable !== void 0 ? { resettable: body.resettable } : {}
338
+ };
339
+ }
340
+ /**
341
+ * Cross-type sweep of load-time validation results — calls
342
+ * `GET /meta/diagnostics`. Returns every entry the framework
343
+ * considers invalid (or, when `severity: 'warning'`, also entries
344
+ * with only warnings).
345
+ *
346
+ * Used by the Studio's governance overview page and by the
347
+ * directory page to show "N invalid" badges per metadata type.
348
+ */
349
+ async diagnostics(options = {}) {
350
+ const params = [];
351
+ if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
352
+ if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
353
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
354
+ const qs = params.length ? `?${params.join("&")}` : "";
355
+ const url = `${this.base}/diagnostics${qs}`;
356
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
357
+ if (res.status === 404) {
358
+ return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
359
+ }
360
+ if (!res.ok) throw await parseError(res);
361
+ const data = await res.json();
362
+ return {
363
+ entries: Array.isArray(data.entries) ? data.entries : [],
364
+ total: typeof data.total === "number" ? data.total : 0,
365
+ scannedTypes: typeof data.scannedTypes === "number" ? data.scannedTypes : 0,
366
+ scannedItems: typeof data.scannedItems === "number" ? data.scannedItems : 0,
367
+ stats: data.stats && typeof data.stats === "object" ? data.stats : {}
368
+ };
369
+ }
370
+ /**
371
+ * Find every metadata item that references this one (Phase 3a). Useful
372
+ * for pre-delete impact analysis: "Are any views pointing at this
373
+ * object before I drop it?".
374
+ */
375
+ async references(type, name) {
376
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
377
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
378
+ if (res.status === 404) return [];
379
+ if (!res.ok) throw await parseError(res);
380
+ const data = await res.json();
381
+ return Array.isArray(data) ? data : data?.items ?? [];
382
+ }
383
+ /**
384
+ * Reset a metadata customization overlay back to the artifact default.
385
+ * Idempotent: returns the result even when no overlay row existed.
386
+ * Pass `state: 'draft'` to discard the pending draft only (keeps the
387
+ * published overlay intact) — useful for a Studio "Discard draft" button.
388
+ */
389
+ async reset(type, name, options = {}) {
390
+ const qs = options.state === "draft" ? "?state=draft" : "";
391
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
392
+ const headers = { ...this.headers };
393
+ if (options.ifMatch) headers["If-Match"] = options.ifMatch;
394
+ if (options.actor) headers["X-Actor"] = options.actor;
395
+ const res = await this.fetchImpl(url, { method: "DELETE", headers });
497
396
  if (!res.ok) throw await parseError(res);
498
397
  return await res.json();
499
398
  }
500
399
  /**
501
- * Read the pending draft body for an item (`?state=draft`). Returns
502
- * `null` when there is no draft pending. Draft reads do NOT fall
503
- * back to the published overlay or the artifact registry — a `null`
504
- * unambiguously means "nothing to publish".
505
- *
506
- * Note: the framework wraps draft responses in an envelope
507
- * `{ type, name, item }` (matching `getMetaItem`); callers should
508
- * read `.item` to get the body. The legacy `get()` returns the
509
- * unwrapped body, so this method preserves that asymmetry by
510
- * returning whatever the server sent.
400
+ * Promote the pending draft of an item to the active overlay
401
+ * (POST `/meta/:type/:name/publish`). Returns
402
+ * `{ success, version, seq, message }`. Throws a `404 no_draft` if
403
+ * nothing is pending and `409 metadata_conflict` if the published
404
+ * overlay moved while the draft was sitting.
511
405
  */
512
- async getDraft(type, name, options = {}) {
513
- return this.get(type, name, { state: "draft", ...options.packageId ? { packageId: options.packageId } : {} });
406
+ async publish(type, name, options = {}) {
407
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
408
+ const headers = {
409
+ ...this.headers,
410
+ "Content-Type": "application/json"
411
+ };
412
+ if (options.actor) headers["X-Actor"] = options.actor;
413
+ const res = await this.fetchImpl(url, {
414
+ method: "POST",
415
+ headers,
416
+ body: JSON.stringify(options.message ? { message: options.message } : {})
417
+ });
418
+ if (!res.ok) throw await parseError(res);
419
+ return await res.json();
514
420
  }
515
421
  /**
516
- * Save (PUT) a metadata item. The framework accepts both the bare
517
- * item payload and the `{ item: ... }` / `{ metadata: ... }`
518
- * envelopes; we send bare for consistency. Pass `mode: 'draft'` to
519
- * stage the change without publishing (Studio's "Save" button).
422
+ * Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
423
+ * The server reads the history row at `toVersion`, writes its body back
424
+ * as the active overlay with `operation_type='revert'`. Returns
425
+ * `{ success, version, seq, restoredFromVersion, message }`. Throws
426
+ * `404 version_not_found` for unknown versions and `409 version_not_restorable`
427
+ * when the target version is a delete tombstone.
520
428
  */
521
- async save(type, name, item, options = {}) {
522
- if (!name || !String(name).trim()) {
523
- throw new Error(
524
- `MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
525
- );
526
- }
527
- const params = [];
528
- if (options.force) params.push("force=true");
529
- if (options.mode === "draft") params.push("mode=draft");
530
- if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
531
- const qs = params.length ? `?${params.join("&")}` : "";
532
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
429
+ async rollback(type, name, toVersion, options = {}) {
430
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
533
431
  const headers = {
534
432
  ...this.headers,
535
433
  "Content-Type": "application/json"
536
434
  };
537
- if (options.ifMatch) headers["If-Match"] = options.ifMatch;
538
435
  if (options.actor) headers["X-Actor"] = options.actor;
539
436
  const res = await this.fetchImpl(url, {
540
- method: "PUT",
437
+ method: "POST",
541
438
  headers,
542
- body: JSON.stringify(item)
439
+ body: JSON.stringify({
440
+ toVersion,
441
+ ...options.message ? { message: options.message } : {}
442
+ })
443
+ });
444
+ if (!res.ok) throw await parseError(res);
445
+ return await res.json();
446
+ }
447
+ /**
448
+ * Compute a structured top-level key diff between two history versions
449
+ * (GET `/meta/:type/:name/diff?from=&to=`). Returns
450
+ * `{ added, removed, changed }` where each entry carries a `path` and
451
+ * the relevant value(s). Useful for "what changed in this draft?" and
452
+ * pre-rollback previews.
453
+ */
454
+ async diff(type, name, fromVersion, toVersion) {
455
+ const params = new URLSearchParams();
456
+ if (fromVersion !== void 0) params.set("from", String(fromVersion));
457
+ if (toVersion !== void 0) params.set("to", String(toVersion));
458
+ const qs = params.toString();
459
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ""}`;
460
+ const res = await this.fetchImpl(url, {
461
+ method: "GET",
462
+ headers: this.headers,
463
+ cache: "no-store"
543
464
  });
544
465
  if (!res.ok) throw await parseError(res);
545
466
  return await res.json();
546
467
  }
468
+ /** Fetch the durable history (change log) for one metadata item. */
469
+ async history(type, name, options = {}) {
470
+ const params = new URLSearchParams();
471
+ if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
472
+ if (options.limit !== void 0) params.set("limit", String(options.limit));
473
+ const qs = params.toString();
474
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
475
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
476
+ if (!res.ok) throw await parseError(res);
477
+ return await res.json();
478
+ }
479
+ /**
480
+ * Fetch the protection-audit trail for one metadata item
481
+ * (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
482
+ * delete/reset attempt — allowed, denied, or forced — so the
483
+ * Studio "审计日志 / Audit log" tab can render who tried what
484
+ * and whether a lock blocked it.
485
+ */
486
+ async audit(type, name, options = {}) {
487
+ const params = new URLSearchParams();
488
+ if (options.limit !== void 0) params.set("limit", String(options.limit));
489
+ const qs = params.toString();
490
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ""}`;
491
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
492
+ if (!res.ok) throw await parseError(res);
493
+ return await res.json();
494
+ }
495
+ };
496
+
497
+ // ../types/src/index.ts
498
+ import {
499
+ defineStack,
500
+ ObjectStackSchema,
501
+ ObjectStackDefinitionSchema
502
+ } from "@objectstack/spec";
503
+
504
+ // ../types/src/error-code.ts
505
+ function errorCodeIs(err, code) {
506
+ const raw = err?.code;
507
+ if (typeof raw !== "string" || raw.length === 0) return false;
508
+ return raw.toUpperCase() === code.toUpperCase();
509
+ }
510
+ function errorCodeIsAnyOf(err, codes) {
511
+ return codes.some((c) => errorCodeIs(err, c));
512
+ }
513
+
514
+ // ../core/src/utils/filter-converter.ts
515
+ import { normalizeFilterOperator } from "@objectstack/spec/ui";
516
+ var FilterOperatorError = class extends Error {
517
+ constructor(message) {
518
+ super(message);
519
+ __publicField(this, "code", "INVALID_FILTER");
520
+ __publicField(this, "httpStatus", 400);
521
+ this.name = "FilterOperatorError";
522
+ }
523
+ };
524
+ function convertOperatorToAST(operator) {
525
+ const operatorMap = {
526
+ "$eq": "=",
527
+ "$ne": "!=",
528
+ "$gt": ">",
529
+ "$gte": ">=",
530
+ "$lt": "<",
531
+ "$lte": "<=",
532
+ "$in": "in",
533
+ "$nin": "nin",
534
+ "$notin": "nin",
535
+ "$between": "between",
536
+ "$contains": "contains",
537
+ "$notContains": "notcontains",
538
+ "$notcontains": "notcontains",
539
+ "$startsWith": "startswith",
540
+ "$startswith": "startswith",
541
+ "$endsWith": "endswith",
542
+ "$endswith": "endswith"
543
+ };
544
+ return operatorMap[operator] || null;
545
+ }
546
+ function convertFiltersToAST(filter) {
547
+ const conditions = [];
548
+ for (const [field, value] of Object.entries(filter)) {
549
+ if (value === null || value === void 0) continue;
550
+ if (typeof value === "object" && !Array.isArray(value)) {
551
+ for (const [operator, operatorValue] of Object.entries(value)) {
552
+ if (operator === "$regex") {
553
+ throw new FilterOperatorError(
554
+ `[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.`
555
+ );
556
+ }
557
+ if (operator === "$null") {
558
+ conditions.push([field, operatorValue ? "is_null" : "is_not_null", true]);
559
+ continue;
560
+ }
561
+ if (operator === "$exists") {
562
+ conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
563
+ continue;
564
+ }
565
+ const astOperator = convertOperatorToAST(operator);
566
+ if (astOperator) {
567
+ conditions.push([field, astOperator, operatorValue]);
568
+ } else {
569
+ throw new FilterOperatorError(
570
+ `[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.`
571
+ );
572
+ }
573
+ }
574
+ } else {
575
+ conditions.push([field, "=", value]);
576
+ }
577
+ }
578
+ if (conditions.length === 0) {
579
+ return filter;
580
+ }
581
+ if (conditions.length === 1) {
582
+ return conditions[0];
583
+ }
584
+ return ["and", ...conditions];
585
+ }
586
+
587
+ // ../core/src/adapters/batchTransaction.ts
588
+ function idOf(rec) {
589
+ if (rec == null) return void 0;
590
+ return rec.id ?? rec._id ?? rec.recordId;
591
+ }
592
+ function resolveRefs(data, results) {
593
+ const out = {};
594
+ for (const [key, value] of Object.entries(data)) {
595
+ if (value && typeof value === "object" && !Array.isArray(value) && typeof value.$ref === "number") {
596
+ const refIndex = value.$ref;
597
+ const id = idOf(results[refIndex]);
598
+ if (id === void 0) {
599
+ throw new Error(
600
+ `batchTransaction: field "${key}" references operation ${refIndex}, which has not produced an id (forward or invalid $ref)`
601
+ );
602
+ }
603
+ out[key] = id;
604
+ } else {
605
+ out[key] = value;
606
+ }
607
+ }
608
+ return out;
609
+ }
610
+ async function emulateBatchTransaction(dataSource, operations) {
611
+ const results = [];
612
+ const created = [];
613
+ try {
614
+ for (const op of operations) {
615
+ const action = op.action ?? "create";
616
+ if (action === "create") {
617
+ const record = await dataSource.create(
618
+ op.object,
619
+ resolveRefs(op.data ?? {}, results)
620
+ );
621
+ results.push(record);
622
+ const id = idOf(record);
623
+ if (id) created.push({ object: op.object, id });
624
+ } else if (action === "update") {
625
+ if (op.id == null) {
626
+ throw new Error(`batchTransaction: update on "${op.object}" requires an id`);
627
+ }
628
+ results.push(
629
+ await dataSource.update(
630
+ op.object,
631
+ op.id,
632
+ resolveRefs(op.data ?? {}, results)
633
+ )
634
+ );
635
+ } else {
636
+ if (op.id == null) {
637
+ throw new Error(`batchTransaction: delete on "${op.object}" requires an id`);
638
+ }
639
+ results.push(await dataSource.delete(op.object, op.id));
640
+ }
641
+ }
642
+ return { results };
643
+ } catch (err) {
644
+ if (created.length > 0) {
645
+ await Promise.allSettled(
646
+ [...created].reverse().map((c) => dataSource.delete(c.object, c.id))
647
+ );
648
+ }
649
+ throw err;
650
+ }
651
+ }
652
+
653
+ // ../core/src/utils/reference-keys.ts
654
+ function normalizeFieldReferenceKeys(fieldDef) {
655
+ if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
656
+ const f = fieldDef;
657
+ const target = f.reference_to ?? f.reference ?? f.referenceTo;
658
+ if (target == null || target === "") return fieldDef;
659
+ if (f.reference_to === void 0) f.reference_to = target;
660
+ if (f.reference === void 0) f.reference = target;
661
+ return fieldDef;
662
+ }
663
+ function normalizeSchemaReferenceKeys(schema) {
664
+ const fields = schema && typeof schema === "object" ? schema.fields : null;
665
+ if (!fields || typeof fields !== "object") return schema;
666
+ const defs = Array.isArray(fields) ? fields : Object.values(fields);
667
+ for (const def of defs) normalizeFieldReferenceKeys(def);
668
+ return schema;
669
+ }
670
+
671
+ // src/cache/MetadataCache.ts
672
+ var MetadataCache = class {
547
673
  /**
548
- * Publish a single pending draft BY REFERENCE — promotes the draft row for
549
- * `(type, name)` into the active overlay and drops the draft. Works for ANY
550
- * draft, including ones with no `packageId` binding (which the package-scoped
551
- * `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
552
- * exact set returned by {@link listDrafts} without needing a package.
553
- *
554
- * Returns the server result. For `seed` drafts the protocol also materializes
555
- * the rows and reports under `seedApplied` — a data problem never fails the
556
- * publish, so callers should check `seedApplied?.success` and warn the user
557
- * rather than assume the data went live.
674
+ * Create a new MetadataCache instance
675
+ *
676
+ * @param options - Configuration options
677
+ * @param options.maxSize - Maximum number of entries (default: 100)
678
+ * @param options.ttl - Time to live in milliseconds (default: 5 minutes)
558
679
  */
559
- async publishDraft(type, name) {
560
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
561
- const res = await this.fetchImpl(url, {
562
- method: "POST",
563
- headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
564
- body: "{}"
565
- });
566
- if (!res.ok) throw await parseError(res);
567
- const body = await res.json().catch(() => ({}));
568
- const inner = body?.data && typeof body.data === "object" ? body.data : body;
569
- return inner;
680
+ constructor(options = {}) {
681
+ __publicField(this, "cache");
682
+ __publicField(this, "inflight");
683
+ __publicField(this, "maxSize");
684
+ __publicField(this, "ttl");
685
+ __publicField(this, "stats");
686
+ this.cache = /* @__PURE__ */ new Map();
687
+ this.inflight = /* @__PURE__ */ new Map();
688
+ this.maxSize = options.maxSize || 100;
689
+ this.ttl = options.ttl || 5 * 60 * 1e3;
690
+ this.stats = {
691
+ hits: 0,
692
+ misses: 0,
693
+ evictions: 0,
694
+ coalesced: 0
695
+ };
570
696
  }
571
697
  /**
572
- * Get the 3-state layered view of a metadata item (Phase 3a). Returns
573
- * `code` (the artifact / fallback default), `overlay` (the saved
574
- * customisation, if any), and `effective` (what the runtime sees).
698
+ * Get a value from cache or fetch it using the provided fetcher function
699
+ *
700
+ * @param key - Cache key
701
+ * @param fetcher - Async function to fetch data if not in cache
702
+ * @returns Promise resolving to the cached or fetched data
575
703
  */
576
- async layered(type, name, options = {}) {
577
- const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : "";
578
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
579
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
580
- if (res.status === 404) {
581
- return { code: null, overlay: null, overlayScope: null, effective: null };
704
+ async get(key, fetcher) {
705
+ const now = Date.now();
706
+ const cached = this.cache.get(key);
707
+ if (cached) {
708
+ const age = now - cached.timestamp;
709
+ if (age < this.ttl) {
710
+ cached.accessCount++;
711
+ cached.lastAccessed = now;
712
+ this.stats.hits++;
713
+ this.cache.delete(key);
714
+ this.cache.set(key, cached);
715
+ return cached.data;
716
+ } else {
717
+ this.cache.delete(key);
718
+ }
582
719
  }
583
- if (!res.ok) throw await parseError(res);
584
- const body = await res.json();
585
- const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
586
- if (!hasEnvelope) {
587
- return {
588
- code: null,
589
- overlay: null,
590
- overlayScope: null,
591
- effective: body
592
- };
720
+ const existing = this.inflight.get(key);
721
+ if (existing) {
722
+ this.stats.coalesced++;
723
+ return existing;
593
724
  }
594
- return {
595
- code: body.code ?? null,
596
- overlay: body.overlay ?? null,
597
- overlayScope: body.overlayScope ?? null,
598
- effective: body.effective ?? null,
599
- ...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
600
- // ADR-0010 Phase 4 — pass through the metadata-protection envelope so
601
- // the editor can render lock affordances without a second round trip.
602
- ...body.lock !== void 0 ? { lock: body.lock } : {},
603
- ...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
604
- ...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
605
- ...body.lockDocsUrl !== void 0 ? { lockDocsUrl: body.lockDocsUrl } : {},
606
- ...body.provenance !== void 0 ? { provenance: body.provenance } : {},
607
- ...body.packageId !== void 0 ? { packageId: body.packageId } : {},
608
- ...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
609
- ...body.editable !== void 0 ? { editable: body.editable } : {},
610
- ...body.deletable !== void 0 ? { deletable: body.deletable } : {},
611
- ...body.resettable !== void 0 ? { resettable: body.resettable } : {}
612
- };
725
+ this.stats.misses++;
726
+ const promise = (async () => {
727
+ try {
728
+ const data = await fetcher();
729
+ this.set(key, data);
730
+ return data;
731
+ } finally {
732
+ this.inflight.delete(key);
733
+ }
734
+ })();
735
+ this.inflight.set(key, promise);
736
+ return promise;
613
737
  }
614
738
  /**
615
- * Cross-type sweep of load-time validation results — calls
616
- * `GET /meta/diagnostics`. Returns every entry the framework
617
- * considers invalid (or, when `severity: 'warning'`, also entries
618
- * with only warnings).
619
- *
620
- * Used by the Studio's governance overview page and by the
621
- * directory page to show "N invalid" badges per metadata type.
739
+ * Prime the cache with a pre-fetched value. Useful when a bulk endpoint
740
+ * (e.g. list of all object schemas) returns data that would otherwise
741
+ * be fetched again per item.
622
742
  */
623
- async diagnostics(options = {}) {
624
- const params = [];
625
- if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
626
- if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
627
- if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
628
- const qs = params.length ? `?${params.join("&")}` : "";
629
- const url = `${this.base}/diagnostics${qs}`;
630
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
631
- if (res.status === 404) {
632
- return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
633
- }
634
- if (!res.ok) throw await parseError(res);
635
- const data = await res.json();
636
- return {
637
- entries: Array.isArray(data.entries) ? data.entries : [],
638
- total: typeof data.total === "number" ? data.total : 0,
639
- scannedTypes: typeof data.scannedTypes === "number" ? data.scannedTypes : 0,
640
- scannedItems: typeof data.scannedItems === "number" ? data.scannedItems : 0,
641
- stats: data.stats && typeof data.stats === "object" ? data.stats : {}
642
- };
743
+ prime(key, data) {
744
+ this.set(key, data);
643
745
  }
644
746
  /**
645
- * Find every metadata item that references this one (Phase 3a). Useful
646
- * for pre-delete impact analysis: "Are any views pointing at this
647
- * object before I drop it?".
747
+ * Set a value in the cache
748
+ *
749
+ * @param key - Cache key
750
+ * @param data - Data to cache
648
751
  */
649
- async references(type, name) {
650
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
651
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
652
- if (res.status === 404) return [];
653
- if (!res.ok) throw await parseError(res);
654
- const data = await res.json();
655
- return Array.isArray(data) ? data : data?.items ?? [];
752
+ set(key, data) {
753
+ const now = Date.now();
754
+ if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
755
+ this.evictLRU();
756
+ }
757
+ this.cache.set(key, {
758
+ data,
759
+ timestamp: now,
760
+ accessCount: 1,
761
+ lastAccessed: now
762
+ });
656
763
  }
657
764
  /**
658
- * Reset a metadata customization overlay back to the artifact default.
659
- * Idempotent: returns the result even when no overlay row existed.
660
- * Pass `state: 'draft'` to discard the pending draft only (keeps the
661
- * published overlay intact) — useful for a Studio "Discard draft" button.
765
+ * Evict the least recently used entry
662
766
  */
663
- async reset(type, name, options = {}) {
664
- const qs = options.state === "draft" ? "?state=draft" : "";
665
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
666
- const headers = { ...this.headers };
667
- if (options.ifMatch) headers["If-Match"] = options.ifMatch;
668
- if (options.actor) headers["X-Actor"] = options.actor;
669
- const res = await this.fetchImpl(url, { method: "DELETE", headers });
670
- if (!res.ok) throw await parseError(res);
671
- return await res.json();
767
+ evictLRU() {
768
+ const firstKey = this.cache.keys().next().value;
769
+ if (firstKey !== void 0) {
770
+ this.cache.delete(firstKey);
771
+ this.stats.evictions++;
772
+ }
672
773
  }
673
774
  /**
674
- * Promote the pending draft of an item to the active overlay
675
- * (POST `/meta/:type/:name/publish`). Returns
676
- * `{ success, version, seq, message }`. Throws a `404 no_draft` if
677
- * nothing is pending and `409 metadata_conflict` if the published
678
- * overlay moved while the draft was sitting.
775
+ * Invalidate a specific cache entry or all entries
776
+ *
777
+ * @param key - Optional key to invalidate. If omitted, invalidates all entries
679
778
  */
680
- async publish(type, name, options = {}) {
681
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
682
- const headers = {
683
- ...this.headers,
684
- "Content-Type": "application/json"
685
- };
686
- if (options.actor) headers["X-Actor"] = options.actor;
687
- const res = await this.fetchImpl(url, {
688
- method: "POST",
689
- headers,
690
- body: JSON.stringify(options.message ? { message: options.message } : {})
691
- });
692
- if (!res.ok) throw await parseError(res);
693
- return await res.json();
779
+ invalidate(key) {
780
+ if (key) {
781
+ this.cache.delete(key);
782
+ } else {
783
+ this.cache.clear();
784
+ }
694
785
  }
695
786
  /**
696
- * Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
697
- * The server reads the history row at `toVersion`, writes its body back
698
- * as the active overlay with `operation_type='revert'`. Returns
699
- * `{ success, version, seq, restoredFromVersion, message }`. Throws
700
- * `404 version_not_found` for unknown versions and `409 version_not_restorable`
701
- * when the target version is a delete tombstone.
787
+ * Clear all cache entries and reset statistics
702
788
  */
703
- async rollback(type, name, toVersion, options = {}) {
704
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
705
- const headers = {
706
- ...this.headers,
707
- "Content-Type": "application/json"
789
+ clear() {
790
+ this.cache.clear();
791
+ this.inflight.clear();
792
+ this.stats = {
793
+ hits: 0,
794
+ misses: 0,
795
+ evictions: 0,
796
+ coalesced: 0
708
797
  };
709
- if (options.actor) headers["X-Actor"] = options.actor;
710
- const res = await this.fetchImpl(url, {
711
- method: "POST",
712
- headers,
713
- body: JSON.stringify({
714
- toVersion,
715
- ...options.message ? { message: options.message } : {}
716
- })
717
- });
718
- if (!res.ok) throw await parseError(res);
719
- return await res.json();
720
798
  }
721
799
  /**
722
- * Compute a structured top-level key diff between two history versions
723
- * (GET `/meta/:type/:name/diff?from=&to=`). Returns
724
- * `{ added, removed, changed }` where each entry carries a `path` and
725
- * the relevant value(s). Useful for "what changed in this draft?" and
726
- * pre-rollback previews.
800
+ * Get cache statistics
801
+ *
802
+ * @returns Cache statistics including hit rate
727
803
  */
728
- async diff(type, name, fromVersion, toVersion) {
729
- const params = new URLSearchParams();
730
- if (fromVersion !== void 0) params.set("from", String(fromVersion));
731
- if (toVersion !== void 0) params.set("to", String(toVersion));
732
- const qs = params.toString();
733
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ""}`;
734
- const res = await this.fetchImpl(url, {
735
- method: "GET",
736
- headers: this.headers,
737
- cache: "no-store"
738
- });
739
- if (!res.ok) throw await parseError(res);
740
- return await res.json();
804
+ getStats() {
805
+ const total = this.stats.hits + this.stats.misses;
806
+ const hitRate = total > 0 ? this.stats.hits / total : 0;
807
+ return {
808
+ size: this.cache.size,
809
+ maxSize: this.maxSize,
810
+ hits: this.stats.hits,
811
+ misses: this.stats.misses,
812
+ evictions: this.stats.evictions,
813
+ coalesced: this.stats.coalesced,
814
+ hitRate
815
+ };
741
816
  }
742
- /** Fetch the durable history (change log) for one metadata item. */
743
- async history(type, name, options = {}) {
744
- const params = new URLSearchParams();
745
- if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
746
- if (options.limit !== void 0) params.set("limit", String(options.limit));
747
- const qs = params.toString();
748
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
749
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
750
- if (!res.ok) throw await parseError(res);
751
- return await res.json();
817
+ /**
818
+ * Get a cached value synchronously without triggering a fetch.
819
+ * Returns undefined if not in cache or expired.
820
+ */
821
+ getCachedSync(key) {
822
+ const entry = this.cache.get(key);
823
+ if (!entry) return void 0;
824
+ if (this.ttl > 0 && Date.now() - entry.timestamp > this.ttl) {
825
+ this.cache.delete(key);
826
+ return void 0;
827
+ }
828
+ this.cache.delete(key);
829
+ this.cache.set(key, entry);
830
+ this.stats.hits++;
831
+ return entry.data;
752
832
  }
753
833
  /**
754
- * Fetch the protection-audit trail for one metadata item
755
- * (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
756
- * delete/reset attempt — allowed, denied, or forced — so the
757
- * Studio "审计日志 / Audit log" tab can render who tried what
758
- * and whether a lock blocked it.
834
+ * Check if a key exists in the cache (and is not expired)
835
+ *
836
+ * @param key - Cache key to check
837
+ * @returns true if the key exists and is not expired
759
838
  */
760
- async audit(type, name, options = {}) {
761
- const params = new URLSearchParams();
762
- if (options.limit !== void 0) params.set("limit", String(options.limit));
763
- const qs = params.toString();
764
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ""}`;
765
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
766
- if (!res.ok) throw await parseError(res);
767
- return await res.json();
839
+ has(key) {
840
+ const cached = this.cache.get(key);
841
+ if (!cached) {
842
+ return false;
843
+ }
844
+ const age = Date.now() - cached.timestamp;
845
+ if (age >= this.ttl) {
846
+ this.cache.delete(key);
847
+ return false;
848
+ }
849
+ return true;
768
850
  }
769
851
  };
770
852
 
@@ -955,45 +1037,6 @@ function isErrorType(error, errorClass) {
955
1037
  return error instanceof errorClass;
956
1038
  }
957
1039
 
958
- // src/cloud.ts
959
- var CloudOperations = class {
960
- constructor(getClient) {
961
- __publicField(this, "getClient", getClient);
962
- }
963
- /**
964
- * Deploy an application to the cloud.
965
- */
966
- async deploy(appId, config) {
967
- const client = this.getClient();
968
- const result = await client.cloud?.deploy?.(appId, config);
969
- return result ?? { deploymentId: `deploy-${Date.now()}`, status: "pending" };
970
- }
971
- /**
972
- * Get deployment status.
973
- */
974
- async getDeploymentStatus(deploymentId) {
975
- const client = this.getClient();
976
- const result = await client.cloud?.getDeployment?.(deploymentId);
977
- return result ?? { status: "unknown" };
978
- }
979
- /**
980
- * Search marketplace entries.
981
- */
982
- async searchMarketplace(query, category) {
983
- const client = this.getClient();
984
- const result = await client.cloud?.marketplace?.search?.({ query, category });
985
- return result?.items ?? [];
986
- }
987
- /**
988
- * Install a marketplace plugin.
989
- */
990
- async installPlugin(pluginId) {
991
- const client = this.getClient();
992
- const result = await client.cloud?.marketplace?.install?.(pluginId);
993
- return result ?? { success: false };
994
- }
995
- };
996
-
997
1040
  // src/contracts.ts
998
1041
  function validatePluginContract(contract) {
999
1042
  const errors = [];
@@ -1451,7 +1494,7 @@ var FILTER_OPERATOR_ALIASES = {
1451
1494
  before: "<",
1452
1495
  after: ">"
1453
1496
  };
1454
- function normalizeFilterOperator(op) {
1497
+ function normalizeFilterOperator2(op) {
1455
1498
  if (typeof op !== "string") return null;
1456
1499
  const lower = op.toLowerCase();
1457
1500
  return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
@@ -1480,7 +1523,7 @@ function objectFilterEntryToAST(entry) {
1480
1523
  if (!entry || typeof entry !== "object") return null;
1481
1524
  const field = entry.field;
1482
1525
  const rawOp = entry.operator ?? entry.op ?? "=";
1483
- const op = normalizeFilterOperator(rawOp);
1526
+ const op = normalizeFilterOperator2(rawOp);
1484
1527
  if (!field || !op) return null;
1485
1528
  return [String(field), op, entry.value];
1486
1529
  }
@@ -1578,6 +1621,17 @@ function is404Error(error) {
1578
1621
  const code = typeof err.code === "string" ? err.code : "";
1579
1622
  return errorCodeIsAnyOf({ code }, ["OBJECT_NOT_FOUND", "RECORD_NOT_FOUND"]);
1580
1623
  }
1624
+ var API_ACCESS_DENIED_CODES = [
1625
+ "OBJECT_API_DISABLED",
1626
+ "OBJECT_API_METHOD_NOT_ALLOWED"
1627
+ ];
1628
+ function isApiAccessDeniedError(error) {
1629
+ return errorCodeIsAnyOf(error, API_ACCESS_DENIED_CODES);
1630
+ }
1631
+ var APP_PERMISSION_DENIED_CODE = "PERMISSION_DENIED";
1632
+ function isAppPermissionDeniedError(error) {
1633
+ return errorCodeIs(error, APP_PERMISSION_DENIED_CODE);
1634
+ }
1581
1635
  var AnalyticsNotInstalledError = class extends Error {
1582
1636
  constructor(surface, detail) {
1583
1637
  super(
@@ -1662,6 +1716,19 @@ function normaliseClientError(error) {
1662
1716
  message: typeof e.message === "string" ? e.message : void 0
1663
1717
  });
1664
1718
  }
1719
+ function formatHttpFailureMessage(message, meta) {
1720
+ if (!meta || typeof meta !== "object") return null;
1721
+ const method = typeof meta.method === "string" && meta.method ? meta.method : void 0;
1722
+ const url = typeof meta.url === "string" && meta.url ? meta.url : void 0;
1723
+ const status = typeof meta.status === "number" ? meta.status : typeof meta.statusCode === "number" ? meta.statusCode : void 0;
1724
+ if (!method && !url && status === void 0) return null;
1725
+ const errBody = meta.error && typeof meta.error === "object" ? meta.error : void 0;
1726
+ const rawCode = typeof meta.code === "string" && meta.code || (errBody && typeof errBody.code === "string" ? errBody.code : void 0);
1727
+ const parts = [method ?? "GET", url ?? "<unknown url>"];
1728
+ parts.push(`-> ${status ?? "no status"}`);
1729
+ if (rawCode) parts.push(`[${rawCode}]`);
1730
+ return `${message}: ${parts.join(" ")}`;
1731
+ }
1665
1732
  function createQuietHttpLogger() {
1666
1733
  const isExpected404 = (meta) => {
1667
1734
  if (!meta || typeof meta !== "object") return false;
@@ -1679,12 +1746,15 @@ function createQuietHttpLogger() {
1679
1746
  warn: (message, meta) => console.warn(message, meta ?? ""),
1680
1747
  error: (message, error, meta) => {
1681
1748
  if (isExpected404(meta)) {
1682
- console.debug(`[ObjectStack] ${message} (suppressed expected 404)`, meta);
1749
+ console.debug(
1750
+ `[ObjectStack] ${formatHttpFailureMessage(message, meta) ?? message} (suppressed expected 404)`,
1751
+ meta
1752
+ );
1683
1753
  return;
1684
1754
  }
1685
- console.error(message, error ?? "", meta ?? "");
1755
+ console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? "", meta ?? "");
1686
1756
  },
1687
- fatal: (message, error, meta) => console.error(message, error ?? "", meta ?? ""),
1757
+ fatal: (message, error, meta) => console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? "", meta ?? ""),
1688
1758
  log: (message, ...args) => console.log(message, ...args),
1689
1759
  child: () => logger,
1690
1760
  withTrace: () => logger
@@ -1698,6 +1768,60 @@ function stableStringify(value) {
1698
1768
  const keys = Object.keys(obj).sort();
1699
1769
  return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
1700
1770
  }
1771
+ function sameWireValue(a, b) {
1772
+ if (a === b) return true;
1773
+ const aEmpty = a === null || a === void 0;
1774
+ const bEmpty = b === null || b === void 0;
1775
+ if (aEmpty || bEmpty) return aEmpty && bEmpty;
1776
+ return stableStringify(a) === stableStringify(b);
1777
+ }
1778
+ function withoutNoOpDrops(droppedFields, sent, stored) {
1779
+ if (!sent || !stored || typeof sent !== "object" || typeof stored !== "object") {
1780
+ return droppedFields;
1781
+ }
1782
+ const out = [];
1783
+ for (const e of droppedFields) {
1784
+ const kept = e.fields.filter((f) => {
1785
+ if (!Object.prototype.hasOwnProperty.call(sent, f)) return true;
1786
+ if (!Object.prototype.hasOwnProperty.call(stored, f)) return true;
1787
+ return !sameWireValue(
1788
+ sent[f],
1789
+ stored[f]
1790
+ );
1791
+ });
1792
+ if (kept.length > 0) out.push({ ...e, fields: kept });
1793
+ }
1794
+ return out;
1795
+ }
1796
+ function viewItemObjectName(item) {
1797
+ const spec = item?.list ?? item;
1798
+ return spec?.data?.object ?? spec?.object ?? spec?.objectName;
1799
+ }
1800
+ function unwrapViewDraft(resp) {
1801
+ if (!resp || typeof resp !== "object") return null;
1802
+ const env = resp;
1803
+ const body = "item" in env ? env.item : env;
1804
+ if (!body || typeof body !== "object") return null;
1805
+ const spec = body.list ?? body;
1806
+ if (!spec || typeof spec !== "object") return null;
1807
+ return Object.keys(spec).length > 0 ? spec : null;
1808
+ }
1809
+ function readViewDeleteReceipt(result) {
1810
+ const r = result ?? void 0;
1811
+ return {
1812
+ removed: !!(r?.deleted ?? r?.reset ?? true),
1813
+ ...typeof r?.reset === "boolean" ? { reset: r.reset } : {},
1814
+ ...typeof r?.message === "string" ? { message: r.message } : {}
1815
+ };
1816
+ }
1817
+ function mergeViewPatch(current, partial, viewName, objectName) {
1818
+ return {
1819
+ ...current,
1820
+ ...partial,
1821
+ name: viewName,
1822
+ object: current?.object || current?.data?.object || objectName
1823
+ };
1824
+ }
1701
1825
  var ObjectStackAdapter = class {
1702
1826
  constructor(config) {
1703
1827
  __publicField(this, "client");
@@ -1750,7 +1874,14 @@ var ObjectStackAdapter = class {
1750
1874
  // whose response carried `droppedFields` (framework #3431/#3455) so the app
1751
1875
  // shell can surface a toast instead of the strip passing silently.
1752
1876
  __publicField(this, "writeWarningListeners", /* @__PURE__ */ new Set());
1877
+ // Subscribers registered via onSaveAdvisory(). Emitted after a metadata save
1878
+ // through THIS adapter's `ObjectStackClient` whose 200 carried a non-empty
1879
+ // `advisories` array (#4237; backend objectstack#7435). Sibling of the set
1880
+ // above in every respect except which door produced the event: that one is
1881
+ // record CRUD, this one is the metadata save door.
1882
+ __publicField(this, "saveAdvisoryListeners", /* @__PURE__ */ new Set());
1753
1883
  this.client = new ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
1884
+ this.installSaveAdvisoryInterceptor();
1754
1885
  this.metadataCache = new MetadataCache(config.cache);
1755
1886
  this.autoReconnect = config.autoReconnect ?? true;
1756
1887
  this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3;
@@ -1905,7 +2036,7 @@ var ObjectStackAdapter = class {
1905
2036
  const result = await this.client.data.find(resource, queryOptions);
1906
2037
  return this.normalizeQueryResult(result, params);
1907
2038
  } catch (err) {
1908
- if (is404Error(err)) {
2039
+ if (!isApiAccessDeniedError(err) && is404Error(err)) {
1909
2040
  this.missingResources.add(resource);
1910
2041
  return { data: [], total: 0 };
1911
2042
  }
@@ -2074,12 +2205,14 @@ var ObjectStackAdapter = class {
2074
2205
  * whose response type predates `droppedFields`: the field is read structurally
2075
2206
  * and validated, so an older client (or a backend that never drops) is a no-op.
2076
2207
  */
2077
- notifyDroppedFields(operation, resource, result, id) {
2208
+ notifyDroppedFields(operation, resource, result, id, sent) {
2078
2209
  const dropped = result?.droppedFields;
2079
2210
  if (!Array.isArray(dropped) || dropped.length === 0) return;
2080
- const droppedFields = dropped.filter(
2211
+ const valid = dropped.filter(
2081
2212
  (e) => !!e && typeof e === "object" && Array.isArray(e.fields) && e.fields.length > 0
2082
2213
  );
2214
+ const stored = result?.record;
2215
+ const droppedFields = withoutNoOpDrops(valid, sent, stored);
2083
2216
  if (droppedFields.length === 0) return;
2084
2217
  this.emitWriteWarning({ operation, resource, ...id !== void 0 ? { id } : {}, droppedFields });
2085
2218
  }
@@ -2098,17 +2231,25 @@ var ObjectStackAdapter = class {
2098
2231
  notifyBatchDroppedFields(operations, payload) {
2099
2232
  const dropped = payload?.droppedFields;
2100
2233
  if (!Array.isArray(dropped) || dropped.length === 0) return;
2234
+ const results = payload?.results;
2101
2235
  for (const entry of dropped) {
2102
2236
  if (!entry || typeof entry !== "object") continue;
2103
2237
  const e = entry;
2104
2238
  if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
2105
2239
  const op = typeof e.index === "number" ? operations[e.index] : void 0;
2240
+ const stored = typeof e.index === "number" && Array.isArray(results) ? results[e.index] : void 0;
2241
+ const [live] = withoutNoOpDrops(
2242
+ [{ object: e.object, fields: e.fields, reason: e.reason }],
2243
+ op?.data,
2244
+ stored
2245
+ );
2246
+ if (!live) continue;
2106
2247
  const operation = (op?.action ?? "create") === "create" ? "create" : "update";
2107
2248
  this.emitWriteWarning({
2108
2249
  operation,
2109
2250
  resource: e.object ?? op?.object ?? "",
2110
2251
  ...op?.id !== void 0 && op?.id !== null ? { id: op.id } : {},
2111
- droppedFields: [{ object: e.object, fields: e.fields, reason: e.reason }]
2252
+ droppedFields: [live]
2112
2253
  });
2113
2254
  }
2114
2255
  }
@@ -2123,12 +2264,127 @@ var ObjectStackAdapter = class {
2123
2264
  this.writeWarningListeners.delete(callback);
2124
2265
  };
2125
2266
  }
2267
+ /**
2268
+ * Subscribe to metadata save-advisory events — the runtime authoring gate's
2269
+ * advisory findings on a save that SUCCEEDED (#4237; backend
2270
+ * objectstack#7435). Returns an unsubscribe function.
2271
+ *
2272
+ * Deliberately the same seam as {@link onWriteWarning} (#3431/#3455), which
2273
+ * is what {@link MetadataSaveAdvisoryEvent}'s own declaration already said it
2274
+ * was modelled on. It is a SIBLING of that channel rather than a second
2275
+ * payload pushed down it: `WriteWarningEvent` is a closed shape whose
2276
+ * `droppedFields` is required and means "fields the write legally stripped",
2277
+ * so carrying advisories on it would either force every existing
2278
+ * `onWriteWarning` consumer to grow a branch or make the event lie about what
2279
+ * happened. The seam's SHAPE is what is reused here — a long-lived instance
2280
+ * with a `subscribe → unsubscribe` registration that `AdapterProvider` wires
2281
+ * once — not its event type.
2282
+ *
2283
+ * Why here and not on the config, which is how the other client class does it
2284
+ * (#4133/#4236): `MetadataClient` is minted per component by
2285
+ * `useMetadataClient`, so it has no instance to subscribe to and its sink
2286
+ * rides the factory. `ObjectStackAdapter` is the opposite — one long-lived
2287
+ * instance per app, already carrying this exact subscription pattern.
2288
+ */
2289
+ onSaveAdvisory(callback) {
2290
+ this.saveAdvisoryListeners.add(callback);
2291
+ return () => {
2292
+ this.saveAdvisoryListeners.delete(callback);
2293
+ };
2294
+ }
2295
+ /**
2296
+ * Notify all save-advisory subscribers. Isolated exactly like
2297
+ * {@link emitWriteWarning}: a throwing listener must neither break the save
2298
+ * nor starve the others.
2299
+ */
2300
+ emitSaveAdvisory(event) {
2301
+ for (const listener of this.saveAdvisoryListeners) {
2302
+ try {
2303
+ listener(event);
2304
+ } catch (err) {
2305
+ console.warn("ObjectStackAdapter: save-advisory listener error", err);
2306
+ }
2307
+ }
2308
+ }
2309
+ /**
2310
+ * Install the ONE emitter for the metadata save door (#4237).
2311
+ *
2312
+ * ## Why this seam, and what it covers
2313
+ *
2314
+ * `ObjectStackClient.meta.saveItem` is the second client class that writes
2315
+ * through `PUT /api/v1/meta/:type/:name`, and every one of its callers reaches
2316
+ * it through an adapter this class constructed — the four inside this file
2317
+ * (`updateViewConfig`, the two view paths, `updateDashboard`) via
2318
+ * `this.client`, and every caller outside it via {@link getClient}, which
2319
+ * hands back this same instance: `MetadataService` (app-shell, five saves),
2320
+ * `useNavigationSync`, and plugin-designer's Create/EditAppPage. Wrapping the
2321
+ * method once here therefore covers all of them WITHOUT a per-site edit, which
2322
+ * is the whole point — a toast copied into a dozen call sites is the shape
2323
+ * #4133 rejected for the other client class and it is no better here.
2324
+ *
2325
+ * `meta` is an own, writable property assigned per instance in the SDK's
2326
+ * constructor (`this.meta = { … }`), and the client this adapter builds is
2327
+ * never shared, so the wrap is bounded to an object this adapter owns for its
2328
+ * whole lifetime. It is not a prototype or global patch.
2329
+ *
2330
+ * ## Response shape — measured, not assumed
2331
+ *
2332
+ * The two client classes' envelopes coincide at the top level, which is what
2333
+ * makes `readSaveAdvisories` reusable unchanged across both. `SaveMetaItem-
2334
+ * ResponseSchema` puts `advisories` at the body's top level next to
2335
+ * `success` / `version` / `seq` / `state`, and the SDK's `unwrapResponse`
2336
+ * strips its `{ success, data }` envelope only when the body actually HAS a
2337
+ * `data` key — this body does not, so it is returned verbatim. So the same
2338
+ * reader that `MetadataClient.save` uses reads this response correctly, and
2339
+ * the pins in `onSaveAdvisory.test.ts` drive a real SDK client through a fake
2340
+ * `fetch` rather than stubbing `meta`, so that continues to be measured.
2341
+ *
2342
+ * ## Draft-door honesty (D1)
2343
+ *
2344
+ * Drafts are NEVER gated: the framework returns at its D1 early-return
2345
+ * (`if (args.state !== 'active') return null`) before running a rule, so a
2346
+ * draft save produces no findings to withhold. This client class has no draft
2347
+ * door at all to worry about — the SDK's `saveItem(type, name, item)` takes no
2348
+ * mode and always writes the active door, which is exactly why the gate DOES
2349
+ * run for its callers. `mode` on the emitted event is therefore derived from
2350
+ * the response's own `state` rather than from a request-side flag that does
2351
+ * not exist here: `'draft'` when the server says the row landed as a draft,
2352
+ * `'publish'` otherwise. That keeps the event truthful about which door it
2353
+ * came through instead of hard-coding one.
2354
+ */
2355
+ installSaveAdvisoryInterceptor() {
2356
+ const meta = this.client.meta;
2357
+ const original = meta.saveItem.bind(meta);
2358
+ meta.saveItem = async (type, name, item) => {
2359
+ const result = await original(type, name, item);
2360
+ try {
2361
+ const advisories = readSaveAdvisories(result);
2362
+ if (advisories.length > 0) {
2363
+ this.emitSaveAdvisory({
2364
+ type,
2365
+ name,
2366
+ mode: result?.state === "draft" ? "draft" : "publish",
2367
+ advisories
2368
+ });
2369
+ }
2370
+ } catch (err) {
2371
+ console.warn("ObjectStackAdapter: save-advisory read error", err);
2372
+ }
2373
+ return result;
2374
+ };
2375
+ }
2126
2376
  async create(resource, data) {
2127
2377
  await this.connect();
2128
2378
  try {
2129
2379
  const result = await this.client.data.create(resource, data);
2130
2380
  this.emitMutation({ type: "create", resource, record: { ...result.record } });
2131
- this.notifyDroppedFields("create", resource, result, result.record?.id);
2381
+ this.notifyDroppedFields(
2382
+ "create",
2383
+ resource,
2384
+ result,
2385
+ result.record?.id,
2386
+ data
2387
+ );
2132
2388
  return result.record;
2133
2389
  } catch (err) {
2134
2390
  throw normaliseClientError(err);
@@ -2155,7 +2411,7 @@ var ObjectStackAdapter = class {
2155
2411
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
2156
2412
  );
2157
2413
  this.emitMutation({ type: "update", resource, id, record: { ...result.record } });
2158
- this.notifyDroppedFields("update", resource, result, id);
2414
+ this.notifyDroppedFields("update", resource, result, id, data);
2159
2415
  return result.record;
2160
2416
  } catch (err) {
2161
2417
  throw normaliseClientError(err);
@@ -2176,10 +2432,10 @@ var ObjectStackAdapter = class {
2176
2432
  String(id),
2177
2433
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
2178
2434
  );
2179
- if (result.deleted) {
2435
+ if (result.success) {
2180
2436
  this.emitMutation({ type: "delete", resource, id });
2181
2437
  }
2182
- return result.deleted;
2438
+ return result.success;
2183
2439
  } catch (err) {
2184
2440
  throw normaliseClientError(err);
2185
2441
  }
@@ -2722,6 +2978,8 @@ var ObjectStackAdapter = class {
2722
2978
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
2723
2979
  const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
2724
2980
  err.status = res.status;
2981
+ err.code = errorBody?.code ?? errorBody?.error?.code;
2982
+ err.httpStatus = res.status;
2725
2983
  throw err;
2726
2984
  }
2727
2985
  const body = await res.json();
@@ -2980,42 +3238,109 @@ var ObjectStackAdapter = class {
2980
3238
  return null;
2981
3239
  }
2982
3240
  }
3241
+ /**
3242
+ * **The one place that knows which cache keys a view-row write invalidates.**
3243
+ *
3244
+ * Two reads are cached under view-shaped keys, and both go stale when any
3245
+ * writer touches a view row for `objectName`:
3246
+ *
3247
+ * | reader | key |
3248
+ * |----------------------------|----------------------------|
3249
+ * | {@link getView} | `view:{object}:{viewName}` |
3250
+ * | {@link listViewOverrides} | `view-overrides:{object}` |
3251
+ *
3252
+ * Every writer routes here rather than restating that pair. The repo has now
3253
+ * paid twice for restatement: objectui#3778 removed five copies of a key
3254
+ * (`views:{object}`) that no reader had ever populated, and objectui#4363
3255
+ * fixed four copies that named only half the live set — and objectui#4373 is
3256
+ * the measured proof that the failure recurs by default, because the console's
3257
+ * real create/publish flow writes these same rows through the ADR-0034
3258
+ * metadata seam and never learned the key list at all. A pin suite can only
3259
+ * guard the writers that exist; a mandatory seam makes the next writer
3260
+ * structurally unable to forget.
3261
+ *
3262
+ * The rule is uniform per WRITE, not per branch: a write to a view row drops
3263
+ * both keys, in that order. Draft-addressed writes over-invalidate on purpose
3264
+ * — both readers enumerate PUBLISHED rows, so a draft write stales neither —
3265
+ * because the costs are not symmetric. An unnecessary invalidation costs one
3266
+ * refetch; a missed one costs up to the cache's 5-minute TTL of stale
3267
+ * overrides, and "which half am I in?" is not a question a future writer
3268
+ * should have to re-answer to stay correct.
3269
+ *
3270
+ * Public because the writers are not all in this class: app-shell's ADR-0034
3271
+ * seam (`runtime-metadata-persistence.ts`) and `MetadataService` write the
3272
+ * same rows through `client.meta` / the `/meta` draft-publish API, and they
3273
+ * reach this adapter as the `dataSource` they were already handed.
3274
+ *
3275
+ * @param objectName - Object the view belongs to (the `{object}` in both keys)
3276
+ * @param viewName - The view's canonical `name` — the identity
3277
+ * {@link getView} reads back by and {@link listViewOverrides} keys its map
3278
+ * with. Callers that hold a qualified `<object>.<key>` name pass it as-is:
3279
+ * it is the row key, not a path to be split.
3280
+ */
3281
+ invalidateViewKeys(objectName, viewName) {
3282
+ this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
3283
+ this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
3284
+ }
2983
3285
  /**
2984
3286
  * Batch-fetch all persisted view overrides for an object.
2985
3287
  *
2986
- * Per-view runtime overrides (density, column widths, sort, …) are
2987
- * stored in the metadata registry under key `<objectName>/<viewName>`.
2988
- * Loading them per-view fires N HTTP GETs that return 404 for views
2989
- * the user has never customized — generates console noise on every
2990
- * page load. This batch method performs a single
2991
- * `GET /api/v1/meta/<objectName>` (returns `{type, items}`) and
2992
- * returns a `{viewName: override}` map. Returns an empty map if the
2993
- * server doesn't expose the listing or returns no items.
3288
+ * Per-view runtime overrides (density, column widths, sort, hidden
3289
+ * columns, inlineEdit …) live in the SAME metadata namespace the
3290
+ * write path uses: `type='view'`, `name=<viewId>` (see
3291
+ * {@link updateViewConfig}). Loading them per-view fires N HTTP GETs
3292
+ * that 404 for every view the user never customized — console noise on
3293
+ * every page load. This batch method performs a single
3294
+ * `GET /api/v1/meta/view` (returns `{type, items}`) and narrows the
3295
+ * result to `objectName` client-side, exactly as {@link listViews}
3296
+ * does over the same rows (shared accessor: {@link viewItemObjectName}).
3297
+ *
3298
+ * objectui#3774 — this used to enumerate `GET /api/v1/meta/<objectName>`,
3299
+ * putting the OBJECT name in the metadata TYPE slot. That key space is
3300
+ * disjoint from the one the write path lands in, so the batch map came
3301
+ * back empty for every object, forever, and every saved personalization
3302
+ * read back as "setting didn't save".
3303
+ *
3304
+ * FAILURES REJECT — they are not answered as `{}`. An empty map is an
3305
+ * authoritative "this object has no overrides" (callers may trust it and
3306
+ * skip the per-view reads); a transport/permission failure is "I could
3307
+ * not tell", and reporting the two as the same value is what made
3308
+ * ObjectView's per-view {@link getView} fallback unreachable code. When
3309
+ * we cannot tell, we do not pretend we can. Rejections are not cached
3310
+ * (the cache stores on success only), so a transient failure does not
3311
+ * pin an empty answer for the TTL.
2994
3312
  *
2995
- * Result is cached identically to {@link getView}; saving a view via
2996
- * {@link updateViewConfig} invalidates the cache.
3313
+ * Result is cached identically to {@link getView}, and EVERY view write —
3314
+ * on this adapter and above it — invalidates it through the one seam,
3315
+ * {@link invalidateViewKeys}. For a long time only {@link updateViewConfig}
3316
+ * did (objectui#4363), which left the other three adapter paths stale
3317
+ * for the cache's 5-minute TTL. That gap does not self-heal: the consumer
3318
+ * (`loadViewOverrides`, app-shell `ObjectView`) treats a RESOLVED map as
3319
+ * authoritative and deliberately does not re-probe per view — objectui#3774,
3320
+ * and correct, since re-probing reinstates the 404 flurry the batch read
3321
+ * exists to remove. So a stale map here is served in full, and the per-view
3322
+ * {@link getView} fallback that would have masked it never runs.
2997
3323
  *
2998
3324
  * @param objectName - Object name (e.g. 'lead')
2999
3325
  * @returns Map keyed by view name with the persisted override config
3326
+ * @throws whatever the metadata transport throws — callers that have a
3327
+ * per-view fallback should catch and use it.
3000
3328
  */
3001
3329
  async listViewOverrides(objectName) {
3002
3330
  await this.connect();
3003
- try {
3004
- const cacheKey = `view-overrides:${objectName}`;
3005
- return await this.metadataCache.get(cacheKey, async () => {
3006
- const result = await this.client.meta.getItems(objectName);
3007
- const items = Array.isArray(result?.items) ? result.items : [];
3008
- const out = {};
3009
- for (const it of items) {
3010
- if (!it || typeof it !== "object") continue;
3011
- const key = it.name ?? it.id ?? it._name;
3012
- if (typeof key === "string" && key) out[key] = it;
3013
- }
3014
- return out;
3015
- });
3016
- } catch {
3017
- return {};
3018
- }
3331
+ const cacheKey = `view-overrides:${objectName}`;
3332
+ return await this.metadataCache.get(cacheKey, async () => {
3333
+ const result = await this.client.meta.getItems("view");
3334
+ const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
3335
+ const out = {};
3336
+ for (const it of items) {
3337
+ if (!it || typeof it !== "object") continue;
3338
+ if (viewItemObjectName(it) !== objectName) continue;
3339
+ const key = it.name;
3340
+ if (typeof key === "string" && key) out[key] = it;
3341
+ }
3342
+ return out;
3343
+ });
3019
3344
  }
3020
3345
  /**
3021
3346
  * Get a view definition for an object.
@@ -3065,10 +3390,7 @@ var ObjectStackAdapter = class {
3065
3390
  viewId,
3066
3391
  merged
3067
3392
  );
3068
- const cacheKey = `view:${objectName}:${viewId}`;
3069
- this.metadataCache.invalidate?.(cacheKey);
3070
- this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
3071
- this.metadataCache.invalidate?.(`views:${objectName}`);
3393
+ this.invalidateViewKeys(objectName, viewId);
3072
3394
  if (result && result.item) return result.item;
3073
3395
  return result ?? void 0;
3074
3396
  }
@@ -3079,8 +3401,10 @@ var ObjectStackAdapter = class {
3079
3401
  * match the view spec shape.
3080
3402
  *
3081
3403
  * Returns view spec objects with their canonical `name` as identifier.
3082
- * Filters by `data.object === objectName` (or top-level `object`)
3083
- * client-side because the metadata index is name-only, not field-typed.
3404
+ * Narrows to one object client-side via {@link viewItemObjectName} —
3405
+ * the metadata index is name-only, not field-typed, so the route has no
3406
+ * `?object=` to push the filter down into. {@link listViewOverrides}
3407
+ * reads the same rows through the same accessor.
3084
3408
  */
3085
3409
  async listViews(objectName, options) {
3086
3410
  await this.connect();
@@ -3097,8 +3421,7 @@ var ObjectStackAdapter = class {
3097
3421
  return items.filter((v) => {
3098
3422
  if (!v) return false;
3099
3423
  const spec = v.list ?? v;
3100
- const obj = spec?.data?.object ?? spec?.object ?? spec?.objectName;
3101
- if (obj !== objectName) return false;
3424
+ if (viewItemObjectName(v) !== objectName) return false;
3102
3425
  const viewKind = v.viewKind ?? spec?.viewKind;
3103
3426
  return !(viewKind && FORM_FAMILY.has(viewKind));
3104
3427
  }).map((v) => {
@@ -3159,6 +3482,10 @@ var ObjectStackAdapter = class {
3159
3482
  *
3160
3483
  * Generates a snake_case name if `spec.name` is not provided by appending
3161
3484
  * a short timestamp suffix to the source-name hint.
3485
+ *
3486
+ * Invalidates through {@link invalidateViewKeys}, like every other writer —
3487
+ * see {@link listViewOverrides} for why the batch map is the one that cannot
3488
+ * heal itself (objectui#4363).
3162
3489
  */
3163
3490
  async createView(objectName, spec) {
3164
3491
  await this.connect();
@@ -3178,48 +3505,184 @@ var ObjectStackAdapter = class {
3178
3505
  data: spec?.data || { provider: "object", object: objectName }
3179
3506
  };
3180
3507
  const result = await this.client.meta.saveItem("view", name, fullSpec);
3181
- this.metadataCache.invalidate?.(`views:${objectName}`);
3508
+ this.invalidateViewKeys(objectName, name);
3182
3509
  if (result && result.item) return result.item;
3183
3510
  return fullSpec;
3184
3511
  }
3185
3512
  /**
3186
3513
  * Apply a partial update to an existing overlay view. Reads the current
3187
- * overlay (or seeds from artifact), merges, and writes back. ADR-0005
3188
- * overlay rows store the *full* view document, so partial updates require
3189
- * a read-merge-write cycle.
3514
+ * document, merges, and writes it back. ADR-0005 overlay rows store the
3515
+ * *full* view document, so partial updates require a read-merge-write cycle.
3516
+ *
3517
+ * **Both halves address the same row (#4139).** A view has two possible
3518
+ * homes and the read must resolve the one the write will target:
3519
+ *
3520
+ * - a pending per-item **draft** (`?state=draft` / `?mode=draft`) — where
3521
+ * ADR-0034 stages every runtime-created view, so a view made from the `+`
3522
+ * tab lives ONLY here until an explicit Publish;
3523
+ * - the **published** overlay (`client.meta.getItem` / `saveItem`).
3524
+ *
3525
+ * The draft is probed FIRST, and a hit is merged and written straight back
3526
+ * as a draft. Two things that ordering buys, both load-bearing:
3527
+ *
3528
+ * 1. A draft-only view is no longer invisible to the read. It used to 404,
3529
+ * and a `catch {}` labelled "treat missing as create-equivalent"
3530
+ * substituted `current = {}` — so a rename merged onto nothing and went
3531
+ * out as a `{label, name, object}` partial the server rejects (422),
3532
+ * while the draft row the UI reads back through `?preview=draft` kept the
3533
+ * old label. The edit was lost with no error surfaced to the user.
3534
+ * 2. A draft is never bypassed. Writing the published row while a draft is
3535
+ * pending would put the edit somewhere the draft shadows — and Publish
3536
+ * would then overwrite it with the pre-edit body, losing the change a
3537
+ * second time, later, where nothing connects it to this call.
3538
+ *
3539
+ * A draft edit stays a draft: `mode: 'draft'` keeps ADR-0037's guarantee
3540
+ * that nothing the preview shows goes live until Publish. Renaming a
3541
+ * *published* view (no draft pending) is unchanged — it writes the
3542
+ * published overlay, as before.
3543
+ *
3544
+ * **Both halves invalidate through {@link invalidateViewKeys}** (objectui#4363,
3545
+ * #4373) — same call, same key set, so the draft half's deliberate
3546
+ * over-invalidation is the seam's uniform rule rather than a per-branch
3547
+ * decision this method has to keep re-making.
3548
+ *
3549
+ * @throws when the view resolves in neither home, or when either read fails
3550
+ * for any other reason (network, permission). Both used to be swallowed
3551
+ * and converted into the bad partial write above; a caller that wants a
3552
+ * view created should call {@link createView}, which is the operation that
3553
+ * actually means "create".
3190
3554
  */
3191
3555
  async updateView(objectName, viewName, partial) {
3192
3556
  await this.connect();
3193
- let current = {};
3557
+ const metaClient = this.metadataClient();
3558
+ const draft = unwrapViewDraft(
3559
+ await metaClient.get("view", viewName, { state: "draft" })
3560
+ );
3561
+ if (draft) {
3562
+ const mergedDraft = mergeViewPatch(draft, partial, viewName, objectName);
3563
+ await metaClient.save("view", viewName, mergedDraft, { mode: "draft" });
3564
+ this.invalidateViewKeys(objectName, viewName);
3565
+ return mergedDraft;
3566
+ }
3567
+ let current;
3194
3568
  try {
3195
3569
  const r = await this.client.meta.getItem("view", viewName);
3196
3570
  current = r && (r.item || r) || {};
3197
3571
  if (current?.list) current = current.list;
3198
- } catch {
3572
+ } catch (err) {
3573
+ if (is404Error(err)) {
3574
+ throw Object.assign(
3575
+ new Error(
3576
+ `updateView: view "${viewName}" not found on object "${objectName}" \u2014 no pending draft and no published overlay. Use createView() to create one.`
3577
+ ),
3578
+ { cause: err }
3579
+ );
3580
+ }
3581
+ throw err;
3199
3582
  }
3200
- const merged = {
3201
- ...current,
3202
- ...partial,
3203
- name: viewName,
3204
- object: current?.object || current?.data?.object || objectName
3205
- };
3583
+ const merged = mergeViewPatch(current, partial, viewName, objectName);
3206
3584
  const result = await this.client.meta.saveItem("view", viewName, merged);
3207
- this.metadataCache.invalidate?.(`views:${objectName}`);
3208
- this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
3585
+ this.invalidateViewKeys(objectName, viewName);
3209
3586
  if (result && result.item) return result.item;
3210
3587
  return merged;
3211
3588
  }
3212
3589
  /**
3213
- * Delete an overlay view (reset to artifact default if one exists, or
3214
- * remove entirely if it was a user-created view). Routes to
3215
- * `DELETE /api/v1/meta/view/:name`.
3590
+ * Delete an overlay view — from **every home it has**.
3591
+ *
3592
+ * A view has two possible homes, the same two {@link updateView} addresses:
3593
+ * the pending per-item **draft** (`DELETE /meta/view/:name?state=draft`) and
3594
+ * the **published** overlay (`DELETE /meta/view/:name`). This method used to
3595
+ * issue only the second, and the three cases came out like this (#4479):
3596
+ *
3597
+ * | case | before |
3598
+ * |--------------------|-----------------------------------------------------|
3599
+ * | draft-only view | BUG — the published delete answered `reset:false` / |
3600
+ * | | "nothing to delete", the draft survived, and the tab |
3601
+ * | | was still there after reload |
3602
+ * | published-only | correct — `reset:true`, tab gone |
3603
+ * | published + draft | ACCIDENTALLY correct — the published row went, so |
3604
+ * | | the tab went; the orphan draft stayed behind |
3605
+ *
3606
+ * **Why this is not the mechanical mirror of #4139.** `updateView` probes
3607
+ * the draft first and writes back to whichever home the read resolved, and
3608
+ * that is right for an update in all three cases. Copying it here would be
3609
+ * wrong in the third: `persistRuntimeMetadata` (app-shell) stages EVERY
3610
+ * runtime edit as a draft, so "publish a view, then edit it" routinely
3611
+ * produces a pair — and a draft-first-only delete on a pair discards the
3612
+ * draft and leaves the published row serving the view. That is not Delete
3613
+ * view, it is **Discard draft**, a deliberately different operation that
3614
+ * already exists (`discardRuntimeDraft`, documented as "the published
3615
+ * overlay is untouched"). The clean statement of the asymmetry: for an
3616
+ * update, one home is the right home; for a delete, "remove this view" is
3617
+ * satisfied only when NO home is left serving it.
3618
+ *
3619
+ * So both homes are deleted, **draft first**. The order is load-bearing on
3620
+ * the failure path: a fault between the two calls leaves the PUBLISHED
3621
+ * overlay intact, so the view is still served and the operation is cleanly
3622
+ * retryable. The reverse order would strand a draft-only view — which is
3623
+ * precisely the bug shape above.
3624
+ *
3625
+ * **Two blind calls, no probe.** The framework's `deleteMetaItem` answers a
3626
+ * missing home with a **200** carrying `reset:false` (`"No pending draft
3627
+ * for view/x."` / `"No view 'x' found — nothing to delete."`), never a 404,
3628
+ * so there is nothing for a probe to protect against. `updateView` needs its
3629
+ * probe for a different reason — its read must resolve the row the merge
3630
+ * writes back to — and that reason has no counterpart for a delete.
3631
+ *
3632
+ * **One transport, one error contract.** Both halves go through
3633
+ * {@link MetadataClient} (`reset`), the transport that can express the
3634
+ * `?state=` qualifier and the one `updateView`'s draft half already uses.
3635
+ * The published half used to go through `client.meta.deleteItem`; measured,
3636
+ * that issues the byte-identical request (`DELETE
3637
+ * {baseUrl}/api/v1/meta/view/:name`, no environment scoping is configured on
3638
+ * this adapter), so routing it here costs no addressing change and buys a
3639
+ * single `MetadataError` shape across both calls instead of two.
3640
+ *
3641
+ * @returns `deleted` is true only when no home is left serving the view AND
3642
+ * at least one actually held a row. A view that existed in neither home
3643
+ * still answers `false`, unchanged. The per-home outcomes are additive:
3644
+ * a partial result is observable rather than rounded up to `true`.
3645
+ * @throws when either delete fails, matching {@link updateView}'s
3646
+ * convention of surfacing the fault rather than degrading. A failure of
3647
+ * the PUBLISHED half after the draft was discarded carries the partial
3648
+ * state on the error's `outcome` — "draft gone, overlay left" is exactly
3649
+ * what the old `{ deleted: boolean }` could not express.
3650
+ *
3651
+ * Invalidates through {@link invalidateViewKeys}: the deleted row leaves the
3652
+ * batch override map too, and a ghost entry there is what the object page
3653
+ * would keep applying (objectui#4363). Fired in a `finally`, so it happens
3654
+ * once per call on EVERY outcome including the throw — after a half-failure
3655
+ * the draft row really is gone, and #4363's asymmetry decides it: an
3656
+ * unnecessary invalidation costs one refetch, a missed one costs the cache's
3657
+ * full 5-minute TTL of stale overrides.
3216
3658
  */
3217
3659
  async deleteView(objectName, viewName) {
3218
3660
  await this.connect();
3219
- const result = await this.client.meta.deleteItem("view", viewName);
3220
- this.metadataCache.invalidate?.(`views:${objectName}`);
3221
- this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
3222
- return { deleted: !!(result?.deleted ?? result?.reset ?? true) };
3661
+ const metaClient = this.metadataClient();
3662
+ try {
3663
+ const draft = readViewDeleteReceipt(
3664
+ await metaClient.reset("view", viewName, { state: "draft" })
3665
+ );
3666
+ let published;
3667
+ try {
3668
+ published = readViewDeleteReceipt(await metaClient.reset("view", viewName));
3669
+ } catch (err) {
3670
+ const outcome = {
3671
+ deleted: false,
3672
+ draft,
3673
+ published: { removed: false }
3674
+ };
3675
+ throw Object.assign(
3676
+ new Error(
3677
+ `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.`
3678
+ ),
3679
+ { cause: err, outcome }
3680
+ );
3681
+ }
3682
+ return { deleted: draft.removed || published.removed, draft, published };
3683
+ } finally {
3684
+ this.invalidateViewKeys(objectName, viewName);
3685
+ }
3223
3686
  }
3224
3687
  /**
3225
3688
  * Get an application definition by name or ID.
@@ -3243,6 +3706,51 @@ var ObjectStackAdapter = class {
3243
3706
  return null;
3244
3707
  }
3245
3708
  }
3709
+ /**
3710
+ * Ask the by-name meta app route WHY an app is not in this session's app list
3711
+ * (objectui#4252).
3712
+ *
3713
+ * `GET /api/v1/meta/apps` is filtered per session server-side
3714
+ * (`filterAppForUser`), so an app withheld by `requiredPermissions` and an app
3715
+ * that does not exist are byte-identical there: both are simply absent. A
3716
+ * console reading only that list has one fact and two conditions, and it
3717
+ * renders its copy for the wrong one — "it may still be publishing" over a
3718
+ * permanent authorization decision, which cost a downstream acceptance round
3719
+ * two test batches spent chasing a platform defect that was a missing
3720
+ * permission-set binding.
3721
+ *
3722
+ * The maintainer ruling (2026-08-12) put the answer on the BY-NAME route
3723
+ * rather than in the list, so the enumeration surface is not widened past what
3724
+ * a by-name probe already implies (objectstack#8013 / PR #8135): an app that
3725
+ * exists and whose `requiredPermissions` the session lacks answers `403` with
3726
+ * `PERMISSION_DENIED` in the declared envelope, and absence — a nonexistent
3727
+ * name, an unpublished app, an app gated by an absent optional service —
3728
+ * keeps answering `404 RESOURCE_NOT_FOUND`.
3729
+ *
3730
+ * ## Why this is a separate method and not a flavour of {@link getApp}
3731
+ *
3732
+ * - `getApp` degrades EVERY failure to `null`, which is exactly the
3733
+ * conflation this exists to undo; changing it would silently re-point its
3734
+ * own callers' fallback-to-static-config path.
3735
+ * - `getApp` memoises in `metadataCache`. A verdict about the CALLER must not
3736
+ * be cached beside a document about the APP — one grant, and a cached
3737
+ * denial outlives the session it described.
3738
+ *
3739
+ * Nothing here throws: a probe that cannot reach an answer returns `unknown`
3740
+ * and the caller keeps whatever it was already showing. Only the measured
3741
+ * `code` produces `denied` — never a status, never a message (objectui#4408).
3742
+ *
3743
+ * @param appName - the app name as it appears in the URL segment
3744
+ */
3745
+ async probeAppAccess(appName) {
3746
+ if (!appName) return "unknown";
3747
+ try {
3748
+ await this.client.meta.getItem("app", appName);
3749
+ return "granted";
3750
+ } catch (err) {
3751
+ return isAppPermissionDeniedError(err) ? "denied" : "unknown";
3752
+ }
3753
+ }
3246
3754
  /**
3247
3755
  * Get a page definition from ObjectStack.
3248
3756
  * Uses the metadata API to fetch page layouts.
@@ -3395,7 +3903,17 @@ var ObjectStackAdapter = class {
3395
3903
  * silently returning wrong numbers.
3396
3904
  *
3397
3905
  * @param dataset - An inline dataset definition (draft) OR a saved dataset name.
3398
- * @param selection - Dimension/measure names to project + runtime directives.
3906
+ * @param selection - The spec's {@link DatasetSelection} — dimension/measure
3907
+ * names to project plus runtime directives. This parameter IS the spec type
3908
+ * by reference, never a local restatement of it (objectui#3613): a hand
3909
+ * copy of a contract is a second dialect of it, and the copy this replaced
3910
+ * had already drifted three ways from `@objectstack/spec` — it required
3911
+ * `compareTo.dimension` (optional since objectstack#5011, and resolved by
3912
+ * the EXECUTOR, so requiring it pushed callers into exactly the
3913
+ * consumer-side dimension guess AGENTS.md #0.1 forbids), it widened
3914
+ * `timeDimensions` to `unknown[]` and `runtimeFilter` to
3915
+ * `Record<string, unknown>`, and it had never grown `dateGranularity` at
3916
+ * all. Pinned in `queryDataset.test.ts`.
3399
3917
  */
3400
3918
  async queryDataset(dataset, selection) {
3401
3919
  await this.connect();
@@ -3430,8 +3948,9 @@ var ObjectStackAdapter = class {
3430
3948
  const object = typeof data?.object === "string" ? data.object : void 0;
3431
3949
  const dimensionFields = data?.dimensionFields && typeof data.dimensionFields === "object" ? data.dimensionFields : void 0;
3432
3950
  const drillRawRows = Array.isArray(data?.drillRawRows) ? data.drillRawRows : void 0;
3951
+ const drillRanges = Array.isArray(data?.drillRanges) ? data.drillRanges : void 0;
3433
3952
  const totals = Array.isArray(data?.totals) ? data.totals : void 0;
3434
- return { rows, fields, object, dimensionFields, drillRawRows, totals };
3953
+ return { rows, fields, object, dimensionFields, drillRawRows, drillRanges, totals };
3435
3954
  }
3436
3955
  /** Client-side aggregation fallback */
3437
3956
  /**
@@ -3675,11 +4194,12 @@ function createObjectStackAdapter(config) {
3675
4194
  return new ObjectStackAdapter(config);
3676
4195
  }
3677
4196
  export {
4197
+ API_ACCESS_DENIED_CODES,
4198
+ APP_PERMISSION_DENIED_CODE,
3678
4199
  AnalyticsNotInstalledError,
3679
4200
  AnalyticsQueryRejectedError,
3680
4201
  AuthenticationError,
3681
4202
  BulkOperationError,
3682
- CloudOperations,
3683
4203
  ConcurrentUpdateError,
3684
4204
  ConnectionError,
3685
4205
  DataApiValidationError,
@@ -3698,17 +4218,23 @@ export {
3698
4218
  createErrorFromResponse,
3699
4219
  createObjectStackAdapter,
3700
4220
  createObjectStackUserStateAdapter,
4221
+ createQuietHttpLogger,
4222
+ formatHttpFailureMessage,
3701
4223
  generateContractManifest,
3702
4224
  getSharedDiscovery,
3703
4225
  is404Error,
3704
4226
  isAnalyticsNotInstalledError,
4227
+ isApiAccessDeniedError,
4228
+ isAppPermissionDeniedError,
3705
4229
  isConcurrentUpdateError,
3706
4230
  isErrorType,
3707
4231
  isMalformedFilterError,
3708
4232
  isObjectStackError,
3709
4233
  normaliseClientError,
4234
+ readSaveAdvisories,
3710
4235
  readTransactionalBatchCapability,
3711
4236
  serializeOrderBy,
3712
4237
  snapToGrid,
3713
- validatePluginContract
4238
+ validatePluginContract,
4239
+ viewItemObjectName
3714
4240
  };