@object-ui/data-objectstack 17.3.0 → 17.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +888 -0
  2. package/README.md +3 -11
  3. package/dist/index.cjs +1511 -1115
  4. package/dist/index.d.cts +1222 -824
  5. package/dist/index.d.ts +1222 -824
  6. package/dist/index.js +1503 -1111
  7. package/package.json +5 -6
  8. package/src/aggregate-capability.test.ts +0 -177
  9. package/src/bulkDelete.test.ts +0 -75
  10. package/src/bulkUpdate.test.ts +0 -93
  11. package/src/cache/MetadataCache.test.ts +0 -477
  12. package/src/cache/MetadataCache.ts +0 -289
  13. package/src/cloud.ts +0 -109
  14. package/src/connection.test.ts +0 -188
  15. package/src/contracts.ts +0 -115
  16. package/src/errors.test.ts +0 -426
  17. package/src/errors.ts +0 -293
  18. package/src/exportDownload.test.ts +0 -141
  19. package/src/filter-entry-translation.test.ts +0 -360
  20. package/src/filter-operator-ast-parity.test.ts +0 -71
  21. package/src/getObjectSchema.test.ts +0 -145
  22. package/src/importJob.test.ts +0 -71
  23. package/src/index.ts +0 -3658
  24. package/src/integration.ts +0 -200
  25. package/src/listViews.test.ts +0 -175
  26. package/src/metadata-client.test.ts +0 -303
  27. package/src/metadata-client.ts +0 -849
  28. package/src/occ.test.ts +0 -101
  29. package/src/onMutation.test.ts +0 -562
  30. package/src/onWriteWarning.test.ts +0 -273
  31. package/src/orderby-serialization.test.ts +0 -115
  32. package/src/queryDataset.test.ts +0 -132
  33. package/src/searchAll.test.ts +0 -120
  34. package/src/security.ts +0 -243
  35. package/src/spec-symbol-batch6.test.ts +0 -306
  36. package/src/studio.ts +0 -152
  37. package/src/userState.test.ts +0 -315
  38. package/src/userState.ts +0 -238
  39. package/src/v3-compat.test.ts +0 -240
  40. package/src/validation-error.test.ts +0 -94
package/dist/index.js CHANGED
@@ -5,767 +5,870 @@ 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();
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
+ });
20
18
  }
21
- function errorCodeIsAnyOf(err, codes) {
22
- return codes.some((c) => errorCodeIs(err, c));
19
+ var META_PREFIX = "/meta";
20
+ var API_PREFIX = "/api/v1";
21
+ function buildBase(config) {
22
+ const trimmed = config.baseUrl.replace(/\/+$/, "");
23
+ const scoped = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
24
+ if (/\/api\/v\d+$/i.test(trimmed)) {
25
+ const stripped = trimmed.replace(/\/api\/v\d+$/i, "");
26
+ const scopedNoApi = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
27
+ return `${stripped}${scopedNoApi}`;
28
+ }
29
+ return `${trimmed}${scoped}`;
23
30
  }
24
-
25
- // ../core/src/utils/filter-converter.ts
26
- import { normalizeFilterOperator } from "@objectstack/spec/ui";
27
- var FilterOperatorError = class extends Error {
28
- constructor(message) {
29
- super(message);
30
- __publicField(this, "code", "INVALID_FILTER");
31
- __publicField(this, "httpStatus", 400);
32
- this.name = "FilterOperatorError";
31
+ async function parseError(res) {
32
+ let body;
33
+ try {
34
+ body = await res.json();
35
+ } catch {
36
+ body = await res.text().catch(() => void 0);
33
37
  }
34
- };
35
- function convertOperatorToAST(operator) {
36
- const operatorMap = {
37
- "$eq": "=",
38
- "$ne": "!=",
39
- "$gt": ">",
40
- "$gte": ">=",
41
- "$lt": "<",
42
- "$lte": "<=",
43
- "$in": "in",
44
- "$nin": "nin",
45
- "$notin": "nin",
46
- "$between": "between",
47
- "$contains": "contains",
48
- "$notContains": "notcontains",
49
- "$notcontains": "notcontains",
50
- "$startsWith": "startswith",
51
- "$startswith": "startswith",
52
- "$endsWith": "endswith",
53
- "$endswith": "endswith"
54
- };
55
- return operatorMap[operator] || null;
38
+ const bodyObj = body && typeof body === "object" ? body : void 0;
39
+ const errNode = bodyObj?.error;
40
+ const message = (errNode && typeof errNode === "object" ? errNode.message : errNode) ?? bodyObj?.message ?? `Metadata request failed: ${res.status} ${res.statusText}`;
41
+ const err = new Error(String(message));
42
+ err.status = res.status;
43
+ err.body = body;
44
+ const details = errNode && typeof errNode === "object" ? errNode.details : void 0;
45
+ const issues = details?.issues ?? (errNode && typeof errNode === "object" ? errNode.issues : void 0) ?? bodyObj?.issues;
46
+ if (Array.isArray(issues)) err.issues = issues;
47
+ const code = details?.code ?? (errNode && typeof errNode === "object" ? errNode.code : void 0) ?? bodyObj?.code;
48
+ if (code != null) err.code = String(code);
49
+ return err;
56
50
  }
57
- function convertFiltersToAST(filter) {
58
- const conditions = [];
59
- for (const [field, value] of Object.entries(filter)) {
60
- if (value === null || value === void 0) continue;
61
- if (typeof value === "object" && !Array.isArray(value)) {
62
- for (const [operator, operatorValue] of Object.entries(value)) {
63
- if (operator === "$regex") {
64
- throw new FilterOperatorError(
65
- `[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.`
66
- );
67
- }
68
- if (operator === "$null") {
69
- conditions.push([field, operatorValue ? "is_null" : "is_not_null", true]);
70
- continue;
71
- }
72
- if (operator === "$exists") {
73
- conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
74
- continue;
75
- }
76
- const astOperator = convertOperatorToAST(operator);
77
- if (astOperator) {
78
- conditions.push([field, astOperator, operatorValue]);
79
- } else {
80
- throw new FilterOperatorError(
81
- `[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.`
82
- );
83
- }
84
- }
85
- } else {
86
- conditions.push([field, "=", value]);
87
- }
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
+ }
56
+ var MetadataClient = class _MetadataClient {
57
+ constructor(config) {
58
+ __publicField(this, "base");
59
+ __publicField(this, "fetchImpl");
60
+ __publicField(this, "headers");
61
+ /** ADR-0037: when true, reads render the draft-overlaid world. */
62
+ __publicField(this, "previewDrafts");
63
+ /** #4133 — sink for post-save advisory findings; see the config field. */
64
+ __publicField(this, "onSaveAdvisory");
65
+ this.base = buildBase(config);
66
+ this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
67
+ this.headers = { Accept: "application/json", ...config.headers ?? {} };
68
+ this.previewDrafts = config.previewDrafts === true;
69
+ this.onSaveAdvisory = config.onSaveAdvisory;
88
70
  }
89
- if (conditions.length === 0) {
90
- return filter;
71
+ /** Update the client's environment scope at runtime. */
72
+ withEnvironment(environmentId) {
73
+ return new _MetadataClient({
74
+ baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
75
+ environmentId,
76
+ fetch: this.fetchImpl,
77
+ headers: this.headers,
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 } : {}
84
+ });
91
85
  }
92
- if (conditions.length === 1) {
93
- return conditions[0];
86
+ /**
87
+ * Derive a client whose reads render the draft-overlaid world (or not).
88
+ * Same base/fetch/headers; used by the app-shell to switch the whole
89
+ * renderer tree into ADR-0037 preview mode off one URL flag.
90
+ */
91
+ withPreviewDrafts(previewDrafts) {
92
+ if (previewDrafts === this.previewDrafts) return this;
93
+ const envMatch = this.base.match(/\/api\/v\d+\/environments\/([^/]+)\/meta$/);
94
+ return new _MetadataClient({
95
+ baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
96
+ ...envMatch ? { environmentId: decodeURIComponent(envMatch[1]) } : {},
97
+ fetch: this.fetchImpl,
98
+ headers: this.headers,
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 } : {}
104
+ });
94
105
  }
95
- return ["and", ...conditions];
96
- }
97
-
98
- // ../core/src/adapters/batchTransaction.ts
99
- function idOf(rec) {
100
- if (rec == null) return void 0;
101
- return rec.id ?? rec._id ?? rec.recordId;
102
- }
103
- function resolveRefs(data, results) {
104
- const out = {};
105
- for (const [key, value] of Object.entries(data)) {
106
- if (value && typeof value === "object" && !Array.isArray(value) && typeof value.$ref === "number") {
107
- const refIndex = value.$ref;
108
- const id = idOf(results[refIndex]);
109
- if (id === void 0) {
110
- throw new Error(
111
- `batchTransaction: field "${key}" references operation ${refIndex}, which has not produced an id (forward or invalid $ref)`
112
- );
113
- }
114
- out[key] = id;
115
- } else {
116
- out[key] = value;
106
+ /** List all registered metadata types (returns the registry rows). */
107
+ async listTypes() {
108
+ const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
109
+ if (!res.ok) throw await parseError(res);
110
+ const data = await res.json();
111
+ if (Array.isArray(data)) return data;
112
+ if (data && Array.isArray(data.entries)) return data.entries;
113
+ if (data && Array.isArray(data.items)) return data.items;
114
+ if (data && Array.isArray(data.types)) {
115
+ return data.types.map((t) => ({ type: t }));
117
116
  }
117
+ return [];
118
118
  }
119
- return out;
120
- }
121
- async function emulateBatchTransaction(dataSource, operations) {
122
- const results = [];
123
- const created = [];
124
- try {
125
- for (const op of operations) {
126
- const action = op.action ?? "create";
127
- if (action === "create") {
128
- const record = await dataSource.create(
129
- op.object,
130
- resolveRefs(op.data ?? {}, results)
131
- );
132
- results.push(record);
133
- const id = idOf(record);
134
- if (id) created.push({ object: op.object, id });
135
- } else if (action === "update") {
136
- if (op.id == null) {
137
- throw new Error(`batchTransaction: update on "${op.object}" requires an id`);
138
- }
139
- results.push(
140
- await dataSource.update(
141
- op.object,
142
- op.id,
143
- resolveRefs(op.data ?? {}, results)
144
- )
145
- );
146
- } else {
147
- if (op.id == null) {
148
- throw new Error(`batchTransaction: delete on "${op.object}" requires an id`);
149
- }
150
- results.push(await dataSource.delete(op.object, op.id));
151
- }
152
- }
153
- return { results };
154
- } catch (err) {
155
- if (created.length > 0) {
156
- await Promise.allSettled(
157
- [...created].reverse().map((c) => dataSource.delete(c.object, c.id))
158
- );
119
+ /** List items of a metadata type (e.g. `object`, `field`, `view`). */
120
+ async list(type, options = {}) {
121
+ const params = [];
122
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
123
+ if (this.previewDrafts) params.push("preview=draft");
124
+ const qs = params.length ? `?${params.join("&")}` : "";
125
+ const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
126
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
127
+ if (!res.ok) throw await parseError(res);
128
+ const data = await res.json();
129
+ if (Array.isArray(data)) return data;
130
+ if (data && Array.isArray(data.items)) {
131
+ return data.items;
159
132
  }
160
- throw err;
133
+ return [];
134
+ }
135
+ /**
136
+ * List pending DRAFT items (ADR-0033) — what an AI authored but nobody
137
+ * published yet. `list()` only sees published/active metadata, so a
138
+ * just-built app package looks empty there; this surfaces the drafts so the
139
+ * console can show a "pending changes" view and draft-aware package contents.
140
+ * Optionally narrow by `packageId` and/or `type`. Returns light headers
141
+ * (no body) carrying `packageId` for grouping.
142
+ */
143
+ async listDrafts(options = {}) {
144
+ const params = [];
145
+ if (options.packageId) params.push(`packageId=${encodeURIComponent(options.packageId)}`);
146
+ if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
147
+ const qs = params.length ? `?${params.join("&")}` : "";
148
+ const url = `${this.base}/_drafts${qs}`;
149
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
150
+ if (!res.ok) throw await parseError(res);
151
+ const data = await res.json();
152
+ if (Array.isArray(data)) return data;
153
+ return data?.drafts ?? data?.data?.drafts ?? [];
154
+ }
155
+ /**
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.
164
+ */
165
+ async readItemResponse(type, name, options = {}) {
166
+ const params = [];
167
+ if (options.state === "draft") params.push("state=draft");
168
+ else if (this.previewDrafts) params.push("preview=draft");
169
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
170
+ const qs = params.length ? `?${params.join("&")}` : "";
171
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
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();
161
176
  }
162
- }
163
-
164
- // ../core/src/utils/reference-keys.ts
165
- function normalizeFieldReferenceKeys(fieldDef) {
166
- if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
167
- const f = fieldDef;
168
- const target = f.reference_to ?? f.reference ?? f.referenceTo;
169
- if (target == null || target === "") return fieldDef;
170
- if (f.reference_to === void 0) f.reference_to = target;
171
- if (f.reference === void 0) f.reference = target;
172
- return fieldDef;
173
- }
174
- function normalizeSchemaReferenceKeys(schema) {
175
- const fields = schema && typeof schema === "object" ? schema.fields : null;
176
- if (!fields || typeof fields !== "object") return schema;
177
- const defs = Array.isArray(fields) ? fields : Object.values(fields);
178
- for (const def of defs) normalizeFieldReferenceKeys(def);
179
- return schema;
180
- }
181
-
182
- // src/cache/MetadataCache.ts
183
- var MetadataCache = class {
184
177
  /**
185
- * Create a new MetadataCache instance
186
- *
187
- * @param options - Configuration options
188
- * @param options.maxSize - Maximum number of entries (default: 100)
189
- * @param options.ttl - Time to live in milliseconds (default: 5 minutes)
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.
190
200
  */
191
- constructor(options = {}) {
192
- __publicField(this, "cache");
193
- __publicField(this, "inflight");
194
- __publicField(this, "maxSize");
195
- __publicField(this, "ttl");
196
- __publicField(this, "stats");
197
- this.cache = /* @__PURE__ */ new Map();
198
- this.inflight = /* @__PURE__ */ new Map();
199
- this.maxSize = options.maxSize || 100;
200
- this.ttl = options.ttl || 5 * 60 * 1e3;
201
- this.stats = {
202
- hits: 0,
203
- misses: 0,
204
- evictions: 0,
205
- coalesced: 0
206
- };
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;
207
205
  }
208
206
  /**
209
- * Get a value from cache or fetch it using the provided fetcher function
210
- *
211
- * @param key - Cache key
212
- * @param fetcher - Async function to fetch data if not in cache
213
- * @returns Promise resolving to the cached or fetched data
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.
214
219
  */
215
- async get(key, fetcher) {
216
- const now = Date.now();
217
- const cached = this.cache.get(key);
218
- if (cached) {
219
- const age = now - cached.timestamp;
220
- if (age < this.ttl) {
221
- cached.accessCount++;
222
- cached.lastAccessed = now;
223
- this.stats.hits++;
224
- this.cache.delete(key);
225
- this.cache.set(key, cached);
226
- return cached.data;
227
- } else {
228
- this.cache.delete(key);
229
- }
230
- }
231
- const existing = this.inflight.get(key);
232
- if (existing) {
233
- this.stats.coalesced++;
234
- return existing;
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
+ );
235
237
  }
236
- this.stats.misses++;
237
- const promise = (async () => {
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
+ const res = await this.fetchImpl(url, {
250
+ method: "PUT",
251
+ headers,
252
+ body: JSON.stringify(item)
253
+ });
254
+ if (!res.ok) throw await parseError(res);
255
+ const body = await res.json();
256
+ if (this.onSaveAdvisory) {
238
257
  try {
239
- const data = await fetcher();
240
- this.set(key, data);
241
- return data;
242
- } finally {
243
- this.inflight.delete(key);
258
+ const advisories = readSaveAdvisories(body);
259
+ if (advisories.length > 0) {
260
+ this.onSaveAdvisory({
261
+ type,
262
+ name,
263
+ mode: options.mode === "draft" ? "draft" : "publish",
264
+ advisories
265
+ });
266
+ }
267
+ } catch {
244
268
  }
245
- })();
246
- this.inflight.set(key, promise);
247
- return promise;
269
+ }
270
+ return body;
248
271
  }
249
272
  /**
250
- * Prime the cache with a pre-fetched value. Useful when a bulk endpoint
251
- * (e.g. list of all object schemas) returns data that would otherwise
252
- * be fetched again per item.
273
+ * Publish a single pending draft BY REFERENCE — promotes the draft row for
274
+ * `(type, name)` into the active overlay and drops the draft. Works for ANY
275
+ * draft, including ones with no `packageId` binding (which the package-scoped
276
+ * `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
277
+ * exact set returned by {@link listDrafts} without needing a package.
278
+ *
279
+ * Returns the server result. For `seed` drafts the protocol also materializes
280
+ * the rows and reports under `seedApplied` — a data problem never fails the
281
+ * publish, so callers should check `seedApplied?.success` and warn the user
282
+ * rather than assume the data went live.
253
283
  */
254
- prime(key, data) {
255
- this.set(key, data);
284
+ async publishDraft(type, name) {
285
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
286
+ const res = await this.fetchImpl(url, {
287
+ method: "POST",
288
+ headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
289
+ body: "{}"
290
+ });
291
+ if (!res.ok) throw await parseError(res);
292
+ const body = await res.json().catch(() => ({}));
293
+ const inner = body?.data && typeof body.data === "object" ? body.data : body;
294
+ return inner;
256
295
  }
257
296
  /**
258
- * Set a value in the cache
259
- *
260
- * @param key - Cache key
261
- * @param data - Data to cache
297
+ * Get the 3-state layered view of a metadata item: `code` (the packaged
298
+ * artifact baseline), `overlay` (the tenant customisation row alone) and
299
+ * `effective` (the merged value the runtime sees).
300
+ *
301
+ * Reads **`GET /meta/:type/:name/layers`** — the path the framework declares
302
+ * for this projection, with a response schema of its own
303
+ * (`GetMetaItemLayeredResponseSchema`, objectstack#5882 ruling B). It used to
304
+ * be reached by hanging a `layers` flag on the ordinary item read, which made
305
+ * one route answer two unrelated representations while `packages/spec`
306
+ * declared only one of them. That spelling still answers this same body
307
+ * inside its deprecation window (the response carries RFC 9745
308
+ * `Deprecation: true` and an RFC 8288 `Link: rel="successor-version"` back to
309
+ * this path) and is scheduled for removal upstream, so nothing here may
310
+ * depend on it — the repo-wide ratchet is
311
+ * `scripts/__tests__/layered-read-declared-path-4016.test.ts`.
312
+ *
313
+ * The request is built here rather than delegated to `@objectstack/client`
314
+ * because the SDK expresses no layered read in EITHER spelling: the
315
+ * framework's REST route ledger records this route as `server-only`,
316
+ * "consumed by objectui over plain HTTP", and whether the SDK should express
317
+ * it is an open upstream product call.
318
+ *
319
+ * One behaviour delta rides along with the path, and it is the server's
320
+ * choice rather than ours: the retired flag FELL THROUGH to the plain item
321
+ * read on a backend whose protocol implementation had no layered support,
322
+ * answering the `{ type, name, item }` envelope. A dedicated path refuses to
323
+ * answer a different resource under this one's declared shape, so it returns
324
+ * 501 `NOT_IMPLEMENTED` instead — which surfaces here as a thrown error
325
+ * rather than a view with `code` and `overlay` silently blank.
262
326
  */
263
- set(key, data) {
264
- const now = Date.now();
265
- if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
266
- this.evictLRU();
327
+ async layered(type, name, options = {}) {
328
+ const qs = options.packageId ? `?package=${encodeURIComponent(options.packageId)}` : "";
329
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/layers${qs}`;
330
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
331
+ if (res.status === 404) {
332
+ return { code: null, overlay: null, overlayScope: null, effective: null };
267
333
  }
268
- this.cache.set(key, {
269
- data,
270
- timestamp: now,
271
- accessCount: 1,
272
- lastAccessed: now
273
- });
334
+ if (!res.ok) throw await parseError(res);
335
+ const body = await res.json();
336
+ const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
337
+ if (!hasEnvelope) {
338
+ return {
339
+ code: null,
340
+ overlay: null,
341
+ overlayScope: null,
342
+ effective: body
343
+ };
344
+ }
345
+ return {
346
+ code: body.code ?? null,
347
+ overlay: body.overlay ?? null,
348
+ overlayScope: body.overlayScope ?? null,
349
+ effective: body.effective ?? null,
350
+ ...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
351
+ // ADR-0010 Phase 4 — pass through the metadata-protection envelope so
352
+ // the editor can render lock affordances without a second round trip.
353
+ ...body.lock !== void 0 ? { lock: body.lock } : {},
354
+ ...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
355
+ ...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
356
+ ...body.lockDocsUrl !== void 0 ? { lockDocsUrl: body.lockDocsUrl } : {},
357
+ ...body.provenance !== void 0 ? { provenance: body.provenance } : {},
358
+ ...body.packageId !== void 0 ? { packageId: body.packageId } : {},
359
+ ...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
360
+ ...body.editable !== void 0 ? { editable: body.editable } : {},
361
+ ...body.deletable !== void 0 ? { deletable: body.deletable } : {},
362
+ ...body.resettable !== void 0 ? { resettable: body.resettable } : {}
363
+ };
274
364
  }
275
365
  /**
276
- * Evict the least recently used entry
366
+ * Cross-type sweep of load-time validation results — calls
367
+ * `GET /meta/diagnostics`. Returns every entry the framework
368
+ * considers invalid (or, when `severity: 'warning'`, also entries
369
+ * with only warnings).
370
+ *
371
+ * Used by the Studio's governance overview page and by the
372
+ * directory page to show "N invalid" badges per metadata type.
277
373
  */
278
- evictLRU() {
279
- const firstKey = this.cache.keys().next().value;
280
- if (firstKey !== void 0) {
281
- this.cache.delete(firstKey);
282
- this.stats.evictions++;
374
+ async diagnostics(options = {}) {
375
+ const params = [];
376
+ if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
377
+ if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
378
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
379
+ const qs = params.length ? `?${params.join("&")}` : "";
380
+ const url = `${this.base}/diagnostics${qs}`;
381
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
382
+ if (res.status === 404) {
383
+ return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
283
384
  }
385
+ if (!res.ok) throw await parseError(res);
386
+ const data = await res.json();
387
+ return {
388
+ entries: Array.isArray(data.entries) ? data.entries : [],
389
+ total: typeof data.total === "number" ? data.total : 0,
390
+ scannedTypes: typeof data.scannedTypes === "number" ? data.scannedTypes : 0,
391
+ scannedItems: typeof data.scannedItems === "number" ? data.scannedItems : 0,
392
+ stats: data.stats && typeof data.stats === "object" ? data.stats : {}
393
+ };
284
394
  }
285
395
  /**
286
- * Invalidate a specific cache entry or all entries
287
- *
288
- * @param key - Optional key to invalidate. If omitted, invalidates all entries
396
+ * Find every metadata item that references this one (Phase 3a). Useful
397
+ * for pre-delete impact analysis: "Are any views pointing at this
398
+ * object before I drop it?".
289
399
  */
290
- invalidate(key) {
291
- if (key) {
292
- this.cache.delete(key);
293
- } else {
294
- this.cache.clear();
295
- }
400
+ async references(type, name) {
401
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
402
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
403
+ if (res.status === 404) return [];
404
+ if (!res.ok) throw await parseError(res);
405
+ const data = await res.json();
406
+ return Array.isArray(data) ? data : data?.items ?? [];
296
407
  }
297
408
  /**
298
- * Clear all cache entries and reset statistics
409
+ * Reset a metadata customization overlay back to the artifact default.
410
+ * Idempotent: returns the result even when no overlay row existed.
411
+ * Pass `state: 'draft'` to discard the pending draft only (keeps the
412
+ * published overlay intact) — useful for a Studio "Discard draft" button.
299
413
  */
300
- clear() {
301
- this.cache.clear();
302
- this.inflight.clear();
303
- this.stats = {
304
- hits: 0,
305
- misses: 0,
306
- evictions: 0,
307
- coalesced: 0
414
+ async reset(type, name, options = {}) {
415
+ const qs = options.state === "draft" ? "?state=draft" : "";
416
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
417
+ const headers = { ...this.headers };
418
+ if (options.ifMatch) headers["If-Match"] = options.ifMatch;
419
+ const res = await this.fetchImpl(url, { method: "DELETE", headers });
420
+ if (!res.ok) throw await parseError(res);
421
+ return await res.json();
422
+ }
423
+ /**
424
+ * Promote the pending draft of an item to the active overlay
425
+ * (POST `/meta/:type/:name/publish`). Returns
426
+ * `{ success, version, seq, message }`. Throws a `404 no_draft` if
427
+ * nothing is pending and `409 metadata_conflict` if the published
428
+ * overlay moved while the draft was sitting.
429
+ */
430
+ async publish(type, name, options = {}) {
431
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
432
+ const headers = {
433
+ ...this.headers,
434
+ "Content-Type": "application/json"
308
435
  };
436
+ const res = await this.fetchImpl(url, {
437
+ method: "POST",
438
+ headers,
439
+ body: JSON.stringify(options.message ? { message: options.message } : {})
440
+ });
441
+ if (!res.ok) throw await parseError(res);
442
+ return await res.json();
309
443
  }
310
444
  /**
311
- * Get cache statistics
312
- *
313
- * @returns Cache statistics including hit rate
445
+ * Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
446
+ * The server reads the history row at `toVersion`, writes its body back
447
+ * as the active overlay with `operation_type='revert'`. Returns
448
+ * `{ success, version, seq, restoredFromVersion, message }`. Throws
449
+ * `404 version_not_found` for unknown versions and `409 version_not_restorable`
450
+ * when the target version is a delete tombstone.
314
451
  */
315
- getStats() {
316
- const total = this.stats.hits + this.stats.misses;
317
- const hitRate = total > 0 ? this.stats.hits / total : 0;
318
- return {
319
- size: this.cache.size,
320
- maxSize: this.maxSize,
321
- hits: this.stats.hits,
322
- misses: this.stats.misses,
323
- evictions: this.stats.evictions,
324
- coalesced: this.stats.coalesced,
325
- hitRate
452
+ async rollback(type, name, toVersion, options = {}) {
453
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
454
+ const headers = {
455
+ ...this.headers,
456
+ "Content-Type": "application/json"
326
457
  };
458
+ const res = await this.fetchImpl(url, {
459
+ method: "POST",
460
+ headers,
461
+ body: JSON.stringify({
462
+ toVersion,
463
+ ...options.message ? { message: options.message } : {}
464
+ })
465
+ });
466
+ if (!res.ok) throw await parseError(res);
467
+ return await res.json();
327
468
  }
328
469
  /**
329
- * Get a cached value synchronously without triggering a fetch.
330
- * Returns undefined if not in cache or expired.
470
+ * Compute a structured top-level key diff between two history versions
471
+ * (GET `/meta/:type/:name/diff?from=&to=`). Returns
472
+ * `{ added, removed, changed }` where each entry carries a `path` and
473
+ * the relevant value(s). Useful for "what changed in this draft?" and
474
+ * pre-rollback previews.
331
475
  */
332
- getCachedSync(key) {
333
- const entry = this.cache.get(key);
334
- if (!entry) return void 0;
335
- if (this.ttl > 0 && Date.now() - entry.timestamp > this.ttl) {
336
- this.cache.delete(key);
337
- return void 0;
338
- }
339
- this.cache.delete(key);
340
- this.cache.set(key, entry);
341
- this.stats.hits++;
342
- return entry.data;
476
+ async diff(type, name, fromVersion, toVersion) {
477
+ const params = new URLSearchParams();
478
+ if (fromVersion !== void 0) params.set("from", String(fromVersion));
479
+ if (toVersion !== void 0) params.set("to", String(toVersion));
480
+ const qs = params.toString();
481
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ""}`;
482
+ const res = await this.fetchImpl(url, {
483
+ method: "GET",
484
+ headers: this.headers,
485
+ cache: "no-store"
486
+ });
487
+ if (!res.ok) throw await parseError(res);
488
+ return await res.json();
489
+ }
490
+ /** Fetch the durable history (change log) for one metadata item. */
491
+ async history(type, name, options = {}) {
492
+ const params = new URLSearchParams();
493
+ if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
494
+ if (options.limit !== void 0) params.set("limit", String(options.limit));
495
+ const qs = params.toString();
496
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
497
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
498
+ if (!res.ok) throw await parseError(res);
499
+ return await res.json();
343
500
  }
344
501
  /**
345
- * Check if a key exists in the cache (and is not expired)
346
- *
347
- * @param key - Cache key to check
348
- * @returns true if the key exists and is not expired
502
+ * Fetch the protection-audit trail for one metadata item
503
+ * (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
504
+ * delete/reset attempt — allowed, denied, or forced — so the
505
+ * Studio "审计日志 / Audit log" tab can render who tried what
506
+ * and whether a lock blocked it.
349
507
  */
350
- has(key) {
351
- const cached = this.cache.get(key);
352
- if (!cached) {
353
- return false;
354
- }
355
- const age = Date.now() - cached.timestamp;
356
- if (age >= this.ttl) {
357
- this.cache.delete(key);
358
- return false;
359
- }
360
- return true;
508
+ async audit(type, name, options = {}) {
509
+ const params = new URLSearchParams();
510
+ if (options.limit !== void 0) params.set("limit", String(options.limit));
511
+ const qs = params.toString();
512
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ""}`;
513
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
514
+ if (!res.ok) throw await parseError(res);
515
+ return await res.json();
361
516
  }
362
517
  };
363
518
 
364
- // src/metadata-client.ts
365
- var META_PREFIX = "/meta";
366
- var API_PREFIX = "/api/v1";
367
- function buildBase(config) {
368
- const trimmed = config.baseUrl.replace(/\/+$/, "");
369
- const scoped = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
370
- if (/\/api\/v\d+$/i.test(trimmed)) {
371
- const stripped = trimmed.replace(/\/api\/v\d+$/i, "");
372
- const scopedNoApi = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
373
- return `${stripped}${scopedNoApi}`;
374
- }
375
- return `${trimmed}${scoped}`;
519
+ // ../types/src/index.ts
520
+ import {
521
+ defineStack,
522
+ ObjectStackSchema,
523
+ ObjectStackDefinitionSchema
524
+ } from "@objectstack/spec";
525
+
526
+ // ../types/src/error-code.ts
527
+ function errorCodeIs(err, code) {
528
+ const raw = err?.code;
529
+ if (typeof raw !== "string" || raw.length === 0) return false;
530
+ return raw.toUpperCase() === code.toUpperCase();
376
531
  }
377
- async function parseError(res) {
378
- let body;
379
- try {
380
- body = await res.json();
381
- } catch {
382
- body = await res.text().catch(() => void 0);
383
- }
384
- const bodyObj = body && typeof body === "object" ? body : void 0;
385
- const errNode = bodyObj?.error;
386
- const message = (errNode && typeof errNode === "object" ? errNode.message : errNode) ?? bodyObj?.message ?? `Metadata request failed: ${res.status} ${res.statusText}`;
387
- const err = new Error(String(message));
388
- err.status = res.status;
389
- err.body = body;
390
- const details = errNode && typeof errNode === "object" ? errNode.details : void 0;
391
- const issues = details?.issues ?? (errNode && typeof errNode === "object" ? errNode.issues : void 0) ?? bodyObj?.issues;
392
- if (Array.isArray(issues)) err.issues = issues;
393
- const code = details?.code ?? (errNode && typeof errNode === "object" ? errNode.code : void 0) ?? bodyObj?.code;
394
- if (code != null) err.code = String(code);
395
- return err;
532
+ function errorCodeIsAnyOf(err, codes) {
533
+ return codes.some((c) => errorCodeIs(err, c));
396
534
  }
397
- var MetadataClient = class _MetadataClient {
398
- constructor(config) {
399
- __publicField(this, "base");
400
- __publicField(this, "fetchImpl");
401
- __publicField(this, "headers");
402
- /** ADR-0037: when true, reads render the draft-overlaid world. */
403
- __publicField(this, "previewDrafts");
404
- this.base = buildBase(config);
405
- this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
406
- this.headers = { Accept: "application/json", ...config.headers ?? {} };
407
- this.previewDrafts = config.previewDrafts === true;
408
- }
409
- /** Update the client's environment scope at runtime. */
410
- withEnvironment(environmentId) {
411
- return new _MetadataClient({
412
- baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
413
- environmentId,
414
- fetch: this.fetchImpl,
415
- headers: this.headers,
416
- previewDrafts: this.previewDrafts
417
- });
418
- }
419
- /**
420
- * Derive a client whose reads render the draft-overlaid world (or not).
421
- * Same base/fetch/headers; used by the app-shell to switch the whole
422
- * renderer tree into ADR-0037 preview mode off one URL flag.
423
- */
424
- withPreviewDrafts(previewDrafts) {
425
- if (previewDrafts === this.previewDrafts) return this;
426
- const envMatch = this.base.match(/\/api\/v\d+\/environments\/([^/]+)\/meta$/);
427
- return new _MetadataClient({
428
- baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
429
- ...envMatch ? { environmentId: decodeURIComponent(envMatch[1]) } : {},
430
- fetch: this.fetchImpl,
431
- headers: this.headers,
432
- previewDrafts
433
- });
535
+
536
+ // ../core/src/utils/filter-converter.ts
537
+ import { normalizeFilterOperator } from "@objectstack/spec/ui";
538
+ var FilterOperatorError = class extends Error {
539
+ constructor(message) {
540
+ super(message);
541
+ __publicField(this, "code", "INVALID_FILTER");
542
+ __publicField(this, "httpStatus", 400);
543
+ this.name = "FilterOperatorError";
434
544
  }
435
- /** List all registered metadata types (returns the registry rows). */
436
- async listTypes() {
437
- const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
438
- if (!res.ok) throw await parseError(res);
439
- const data = await res.json();
440
- if (Array.isArray(data)) return data;
441
- if (data && Array.isArray(data.entries)) return data.entries;
442
- if (data && Array.isArray(data.items)) return data.items;
443
- if (data && Array.isArray(data.types)) {
444
- return data.types.map((t) => ({ type: t }));
545
+ };
546
+ function convertOperatorToAST(operator) {
547
+ const operatorMap = {
548
+ "$eq": "=",
549
+ "$ne": "!=",
550
+ "$gt": ">",
551
+ "$gte": ">=",
552
+ "$lt": "<",
553
+ "$lte": "<=",
554
+ "$in": "in",
555
+ "$nin": "nin",
556
+ "$notin": "nin",
557
+ "$between": "between",
558
+ "$contains": "contains",
559
+ "$notContains": "notcontains",
560
+ "$notcontains": "notcontains",
561
+ "$startsWith": "startswith",
562
+ "$startswith": "startswith",
563
+ "$endsWith": "endswith",
564
+ "$endswith": "endswith"
565
+ };
566
+ return operatorMap[operator] || null;
567
+ }
568
+ function convertFiltersToAST(filter) {
569
+ const conditions = [];
570
+ for (const [field, value] of Object.entries(filter)) {
571
+ if (value === null || value === void 0) continue;
572
+ if (typeof value === "object" && !Array.isArray(value)) {
573
+ for (const [operator, operatorValue] of Object.entries(value)) {
574
+ if (operator === "$regex") {
575
+ throw new FilterOperatorError(
576
+ `[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.`
577
+ );
578
+ }
579
+ if (operator === "$null") {
580
+ conditions.push([field, operatorValue ? "is_null" : "is_not_null", true]);
581
+ continue;
582
+ }
583
+ if (operator === "$exists") {
584
+ conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
585
+ continue;
586
+ }
587
+ const astOperator = convertOperatorToAST(operator);
588
+ if (astOperator) {
589
+ conditions.push([field, astOperator, operatorValue]);
590
+ } else {
591
+ throw new FilterOperatorError(
592
+ `[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.`
593
+ );
594
+ }
595
+ }
596
+ } else {
597
+ conditions.push([field, "=", value]);
445
598
  }
446
- return [];
447
599
  }
448
- /** List items of a metadata type (e.g. `object`, `field`, `view`). */
449
- async list(type, options = {}) {
450
- const params = [];
451
- if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
452
- if (this.previewDrafts) params.push("preview=draft");
453
- const qs = params.length ? `?${params.join("&")}` : "";
454
- const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
455
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
456
- if (!res.ok) throw await parseError(res);
457
- const data = await res.json();
458
- if (Array.isArray(data)) return data;
459
- if (data && Array.isArray(data.items)) {
460
- return data.items;
461
- }
462
- return [];
600
+ if (conditions.length === 0) {
601
+ return filter;
463
602
  }
464
- /**
465
- * List pending DRAFT items (ADR-0033) — what an AI authored but nobody
466
- * published yet. `list()` only sees published/active metadata, so a
467
- * just-built app package looks empty there; this surfaces the drafts so the
468
- * console can show a "pending changes" view and draft-aware package contents.
469
- * Optionally narrow by `packageId` and/or `type`. Returns light headers
470
- * (no body) carrying `packageId` for grouping.
471
- */
472
- async listDrafts(options = {}) {
473
- const params = [];
474
- if (options.packageId) params.push(`packageId=${encodeURIComponent(options.packageId)}`);
475
- if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
476
- const qs = params.length ? `?${params.join("&")}` : "";
477
- const url = `${this.base}/_drafts${qs}`;
478
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
479
- if (!res.ok) throw await parseError(res);
480
- const data = await res.json();
481
- if (Array.isArray(data)) return data;
482
- return data?.drafts ?? data?.data?.drafts ?? [];
603
+ if (conditions.length === 1) {
604
+ return conditions[0];
483
605
  }
484
- /**
485
- * Get a single metadata item. Returns the unwrapped item content
486
- * (matching the framework REST handler which calls `res.json(item)`).
487
- * Returns `null` on 404 to keep the call-site ergonomic.
488
- */
489
- async get(type, name, options = {}) {
490
- const params = [];
491
- if (options.state === "draft") params.push("state=draft");
492
- else if (this.previewDrafts) params.push("preview=draft");
493
- if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
494
- const qs = params.length ? `?${params.join("&")}` : "";
495
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
496
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
497
- if (res.status === 404) return null;
498
- if (!res.ok) throw await parseError(res);
499
- return await res.json();
606
+ return ["and", ...conditions];
607
+ }
608
+
609
+ // ../core/src/adapters/batchTransaction.ts
610
+ function idOf(rec) {
611
+ if (rec == null) return void 0;
612
+ return rec.id ?? rec._id ?? rec.recordId;
613
+ }
614
+ function resolveRefs(data, results) {
615
+ const out = {};
616
+ for (const [key, value] of Object.entries(data)) {
617
+ if (value && typeof value === "object" && !Array.isArray(value) && typeof value.$ref === "number") {
618
+ const refIndex = value.$ref;
619
+ const id = idOf(results[refIndex]);
620
+ if (id === void 0) {
621
+ throw new Error(
622
+ `batchTransaction: field "${key}" references operation ${refIndex}, which has not produced an id (forward or invalid $ref)`
623
+ );
624
+ }
625
+ out[key] = id;
626
+ } else {
627
+ out[key] = value;
628
+ }
500
629
  }
501
- /**
502
- * Read the pending draft body for an item (`?state=draft`). Returns
503
- * `null` when there is no draft pending. Draft reads do NOT fall
504
- * back to the published overlay or the artifact registry — a `null`
505
- * unambiguously means "nothing to publish".
506
- *
507
- * Note: the framework wraps draft responses in an envelope
508
- * `{ type, name, item }` (matching `getMetaItem`); callers should
509
- * read `.item` to get the body. The legacy `get()` returns the
510
- * unwrapped body, so this method preserves that asymmetry by
511
- * returning whatever the server sent.
512
- */
513
- async getDraft(type, name, options = {}) {
514
- return this.get(type, name, { state: "draft", ...options.packageId ? { packageId: options.packageId } : {} });
630
+ return out;
631
+ }
632
+ async function emulateBatchTransaction(dataSource, operations) {
633
+ const results = [];
634
+ const created = [];
635
+ try {
636
+ for (const op of operations) {
637
+ const action = op.action ?? "create";
638
+ if (action === "create") {
639
+ const record = await dataSource.create(
640
+ op.object,
641
+ resolveRefs(op.data ?? {}, results)
642
+ );
643
+ results.push(record);
644
+ const id = idOf(record);
645
+ if (id) created.push({ object: op.object, id });
646
+ } else if (action === "update") {
647
+ if (op.id == null) {
648
+ throw new Error(`batchTransaction: update on "${op.object}" requires an id`);
649
+ }
650
+ results.push(
651
+ await dataSource.update(
652
+ op.object,
653
+ op.id,
654
+ resolveRefs(op.data ?? {}, results)
655
+ )
656
+ );
657
+ } else {
658
+ if (op.id == null) {
659
+ throw new Error(`batchTransaction: delete on "${op.object}" requires an id`);
660
+ }
661
+ results.push(await dataSource.delete(op.object, op.id));
662
+ }
663
+ }
664
+ return { results };
665
+ } catch (err) {
666
+ if (created.length > 0) {
667
+ await Promise.allSettled(
668
+ [...created].reverse().map((c) => dataSource.delete(c.object, c.id))
669
+ );
670
+ }
671
+ throw err;
515
672
  }
673
+ }
674
+
675
+ // ../core/src/utils/reference-keys.ts
676
+ function normalizeFieldReferenceKeys(fieldDef) {
677
+ if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
678
+ const f = fieldDef;
679
+ const target = f.reference_to ?? f.reference ?? f.referenceTo;
680
+ if (target == null || target === "") return fieldDef;
681
+ if (f.reference_to === void 0) f.reference_to = target;
682
+ if (f.reference === void 0) f.reference = target;
683
+ return fieldDef;
684
+ }
685
+ function normalizeSchemaReferenceKeys(schema) {
686
+ const fields = schema && typeof schema === "object" ? schema.fields : null;
687
+ if (!fields || typeof fields !== "object") return schema;
688
+ const defs = Array.isArray(fields) ? fields : Object.values(fields);
689
+ for (const def of defs) normalizeFieldReferenceKeys(def);
690
+ return schema;
691
+ }
692
+
693
+ // src/cache/MetadataCache.ts
694
+ var MetadataCache = class {
516
695
  /**
517
- * Save (PUT) a metadata item. The framework accepts both the bare
518
- * item payload and the `{ item: ... }` / `{ metadata: ... }`
519
- * envelopes; we send bare for consistency. Pass `mode: 'draft'` to
520
- * stage the change without publishing (Studio's "Save" button).
696
+ * Create a new MetadataCache instance
697
+ *
698
+ * @param options - Configuration options
699
+ * @param options.maxSize - Maximum number of entries (default: 100)
700
+ * @param options.ttl - Time to live in milliseconds (default: 5 minutes)
521
701
  */
522
- async save(type, name, item, options = {}) {
523
- if (!name || !String(name).trim()) {
524
- throw new Error(
525
- `MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
526
- );
527
- }
528
- const params = [];
529
- if (options.force) params.push("force=true");
530
- if (options.mode === "draft") params.push("mode=draft");
531
- if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
532
- const qs = params.length ? `?${params.join("&")}` : "";
533
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
534
- const headers = {
535
- ...this.headers,
536
- "Content-Type": "application/json"
702
+ constructor(options = {}) {
703
+ __publicField(this, "cache");
704
+ __publicField(this, "inflight");
705
+ __publicField(this, "maxSize");
706
+ __publicField(this, "ttl");
707
+ __publicField(this, "stats");
708
+ this.cache = /* @__PURE__ */ new Map();
709
+ this.inflight = /* @__PURE__ */ new Map();
710
+ this.maxSize = options.maxSize || 100;
711
+ this.ttl = options.ttl || 5 * 60 * 1e3;
712
+ this.stats = {
713
+ hits: 0,
714
+ misses: 0,
715
+ evictions: 0,
716
+ coalesced: 0
537
717
  };
538
- if (options.ifMatch) headers["If-Match"] = options.ifMatch;
539
- if (options.actor) headers["X-Actor"] = options.actor;
540
- const res = await this.fetchImpl(url, {
541
- method: "PUT",
542
- headers,
543
- body: JSON.stringify(item)
544
- });
545
- if (!res.ok) throw await parseError(res);
546
- return await res.json();
547
718
  }
548
719
  /**
549
- * Publish a single pending draft BY REFERENCE — promotes the draft row for
550
- * `(type, name)` into the active overlay and drops the draft. Works for ANY
551
- * draft, including ones with no `packageId` binding (which the package-scoped
552
- * `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
553
- * exact set returned by {@link listDrafts} without needing a package.
554
- *
555
- * Returns the server result. For `seed` drafts the protocol also materializes
556
- * the rows and reports under `seedApplied` — a data problem never fails the
557
- * publish, so callers should check `seedApplied?.success` and warn the user
558
- * rather than assume the data went live.
720
+ * Get a value from cache or fetch it using the provided fetcher function
721
+ *
722
+ * @param key - Cache key
723
+ * @param fetcher - Async function to fetch data if not in cache
724
+ * @returns Promise resolving to the cached or fetched data
559
725
  */
560
- async publishDraft(type, name) {
561
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
562
- const res = await this.fetchImpl(url, {
563
- method: "POST",
564
- headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
565
- body: "{}"
566
- });
567
- if (!res.ok) throw await parseError(res);
568
- const body = await res.json().catch(() => ({}));
569
- const inner = body?.data && typeof body.data === "object" ? body.data : body;
570
- return inner;
726
+ async get(key, fetcher) {
727
+ const now = Date.now();
728
+ const cached = this.cache.get(key);
729
+ if (cached) {
730
+ const age = now - cached.timestamp;
731
+ if (age < this.ttl) {
732
+ cached.accessCount++;
733
+ cached.lastAccessed = now;
734
+ this.stats.hits++;
735
+ this.cache.delete(key);
736
+ this.cache.set(key, cached);
737
+ return cached.data;
738
+ } else {
739
+ this.cache.delete(key);
740
+ }
741
+ }
742
+ const existing = this.inflight.get(key);
743
+ if (existing) {
744
+ this.stats.coalesced++;
745
+ return existing;
746
+ }
747
+ this.stats.misses++;
748
+ const promise = (async () => {
749
+ try {
750
+ const data = await fetcher();
751
+ this.set(key, data);
752
+ return data;
753
+ } finally {
754
+ this.inflight.delete(key);
755
+ }
756
+ })();
757
+ this.inflight.set(key, promise);
758
+ return promise;
571
759
  }
572
760
  /**
573
- * Get the 3-state layered view of a metadata item (Phase 3a). Returns
574
- * `code` (the artifact / fallback default), `overlay` (the saved
575
- * customisation, if any), and `effective` (what the runtime sees).
761
+ * Prime the cache with a pre-fetched value. Useful when a bulk endpoint
762
+ * (e.g. list of all object schemas) returns data that would otherwise
763
+ * be fetched again per item.
576
764
  */
577
- async layered(type, name, options = {}) {
578
- const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : "";
579
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
580
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
581
- if (res.status === 404) {
582
- return { code: null, overlay: null, overlayScope: null, effective: null };
583
- }
584
- if (!res.ok) throw await parseError(res);
585
- const body = await res.json();
586
- const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
587
- if (!hasEnvelope) {
588
- return {
589
- code: null,
590
- overlay: null,
591
- overlayScope: null,
592
- effective: body
593
- };
594
- }
595
- return {
596
- code: body.code ?? null,
597
- overlay: body.overlay ?? null,
598
- overlayScope: body.overlayScope ?? null,
599
- effective: body.effective ?? null,
600
- ...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
601
- // ADR-0010 Phase 4 — pass through the metadata-protection envelope so
602
- // the editor can render lock affordances without a second round trip.
603
- ...body.lock !== void 0 ? { lock: body.lock } : {},
604
- ...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
605
- ...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
606
- ...body.lockDocsUrl !== void 0 ? { lockDocsUrl: body.lockDocsUrl } : {},
607
- ...body.provenance !== void 0 ? { provenance: body.provenance } : {},
608
- ...body.packageId !== void 0 ? { packageId: body.packageId } : {},
609
- ...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
610
- ...body.editable !== void 0 ? { editable: body.editable } : {},
611
- ...body.deletable !== void 0 ? { deletable: body.deletable } : {},
612
- ...body.resettable !== void 0 ? { resettable: body.resettable } : {}
613
- };
765
+ prime(key, data) {
766
+ this.set(key, data);
614
767
  }
615
768
  /**
616
- * Cross-type sweep of load-time validation results — calls
617
- * `GET /meta/diagnostics`. Returns every entry the framework
618
- * considers invalid (or, when `severity: 'warning'`, also entries
619
- * with only warnings).
620
- *
621
- * Used by the Studio's governance overview page and by the
622
- * directory page to show "N invalid" badges per metadata type.
769
+ * Set a value in the cache
770
+ *
771
+ * @param key - Cache key
772
+ * @param data - Data to cache
623
773
  */
624
- async diagnostics(options = {}) {
625
- const params = [];
626
- if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
627
- if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
628
- if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
629
- const qs = params.length ? `?${params.join("&")}` : "";
630
- const url = `${this.base}/diagnostics${qs}`;
631
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
632
- if (res.status === 404) {
633
- return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
774
+ set(key, data) {
775
+ const now = Date.now();
776
+ if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
777
+ this.evictLRU();
634
778
  }
635
- if (!res.ok) throw await parseError(res);
636
- const data = await res.json();
637
- return {
638
- entries: Array.isArray(data.entries) ? data.entries : [],
639
- total: typeof data.total === "number" ? data.total : 0,
640
- scannedTypes: typeof data.scannedTypes === "number" ? data.scannedTypes : 0,
641
- scannedItems: typeof data.scannedItems === "number" ? data.scannedItems : 0,
642
- stats: data.stats && typeof data.stats === "object" ? data.stats : {}
643
- };
779
+ this.cache.set(key, {
780
+ data,
781
+ timestamp: now,
782
+ accessCount: 1,
783
+ lastAccessed: now
784
+ });
644
785
  }
645
786
  /**
646
- * Find every metadata item that references this one (Phase 3a). Useful
647
- * for pre-delete impact analysis: "Are any views pointing at this
648
- * object before I drop it?".
787
+ * Evict the least recently used entry
649
788
  */
650
- async references(type, name) {
651
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
652
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
653
- if (res.status === 404) return [];
654
- if (!res.ok) throw await parseError(res);
655
- const data = await res.json();
656
- return Array.isArray(data) ? data : data?.items ?? [];
789
+ evictLRU() {
790
+ const firstKey = this.cache.keys().next().value;
791
+ if (firstKey !== void 0) {
792
+ this.cache.delete(firstKey);
793
+ this.stats.evictions++;
794
+ }
657
795
  }
658
796
  /**
659
- * Reset a metadata customization overlay back to the artifact default.
660
- * Idempotent: returns the result even when no overlay row existed.
661
- * Pass `state: 'draft'` to discard the pending draft only (keeps the
662
- * published overlay intact) — useful for a Studio "Discard draft" button.
797
+ * Invalidate a specific cache entry or all entries
798
+ *
799
+ * @param key - Optional key to invalidate. If omitted, invalidates all entries
663
800
  */
664
- async reset(type, name, options = {}) {
665
- const qs = options.state === "draft" ? "?state=draft" : "";
666
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
667
- const headers = { ...this.headers };
668
- if (options.ifMatch) headers["If-Match"] = options.ifMatch;
669
- if (options.actor) headers["X-Actor"] = options.actor;
670
- const res = await this.fetchImpl(url, { method: "DELETE", headers });
671
- if (!res.ok) throw await parseError(res);
672
- return await res.json();
801
+ invalidate(key) {
802
+ if (key) {
803
+ this.cache.delete(key);
804
+ } else {
805
+ this.cache.clear();
806
+ }
673
807
  }
674
808
  /**
675
- * Promote the pending draft of an item to the active overlay
676
- * (POST `/meta/:type/:name/publish`). Returns
677
- * `{ success, version, seq, message }`. Throws a `404 no_draft` if
678
- * nothing is pending and `409 metadata_conflict` if the published
679
- * overlay moved while the draft was sitting.
809
+ * Clear all cache entries and reset statistics
680
810
  */
681
- async publish(type, name, options = {}) {
682
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
683
- const headers = {
684
- ...this.headers,
685
- "Content-Type": "application/json"
811
+ clear() {
812
+ this.cache.clear();
813
+ this.inflight.clear();
814
+ this.stats = {
815
+ hits: 0,
816
+ misses: 0,
817
+ evictions: 0,
818
+ coalesced: 0
686
819
  };
687
- if (options.actor) headers["X-Actor"] = options.actor;
688
- const res = await this.fetchImpl(url, {
689
- method: "POST",
690
- headers,
691
- body: JSON.stringify(options.message ? { message: options.message } : {})
692
- });
693
- if (!res.ok) throw await parseError(res);
694
- return await res.json();
695
820
  }
696
821
  /**
697
- * Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
698
- * The server reads the history row at `toVersion`, writes its body back
699
- * as the active overlay with `operation_type='revert'`. Returns
700
- * `{ success, version, seq, restoredFromVersion, message }`. Throws
701
- * `404 version_not_found` for unknown versions and `409 version_not_restorable`
702
- * when the target version is a delete tombstone.
822
+ * Get cache statistics
823
+ *
824
+ * @returns Cache statistics including hit rate
703
825
  */
704
- async rollback(type, name, toVersion, options = {}) {
705
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
706
- const headers = {
707
- ...this.headers,
708
- "Content-Type": "application/json"
826
+ getStats() {
827
+ const total = this.stats.hits + this.stats.misses;
828
+ const hitRate = total > 0 ? this.stats.hits / total : 0;
829
+ return {
830
+ size: this.cache.size,
831
+ maxSize: this.maxSize,
832
+ hits: this.stats.hits,
833
+ misses: this.stats.misses,
834
+ evictions: this.stats.evictions,
835
+ coalesced: this.stats.coalesced,
836
+ hitRate
709
837
  };
710
- if (options.actor) headers["X-Actor"] = options.actor;
711
- const res = await this.fetchImpl(url, {
712
- method: "POST",
713
- headers,
714
- body: JSON.stringify({
715
- toVersion,
716
- ...options.message ? { message: options.message } : {}
717
- })
718
- });
719
- if (!res.ok) throw await parseError(res);
720
- return await res.json();
721
838
  }
722
839
  /**
723
- * Compute a structured top-level key diff between two history versions
724
- * (GET `/meta/:type/:name/diff?from=&to=`). Returns
725
- * `{ added, removed, changed }` where each entry carries a `path` and
726
- * the relevant value(s). Useful for "what changed in this draft?" and
727
- * pre-rollback previews.
840
+ * Get a cached value synchronously without triggering a fetch.
841
+ * Returns undefined if not in cache or expired.
728
842
  */
729
- async diff(type, name, fromVersion, toVersion) {
730
- const params = new URLSearchParams();
731
- if (fromVersion !== void 0) params.set("from", String(fromVersion));
732
- if (toVersion !== void 0) params.set("to", String(toVersion));
733
- const qs = params.toString();
734
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ""}`;
735
- const res = await this.fetchImpl(url, {
736
- method: "GET",
737
- headers: this.headers,
738
- cache: "no-store"
739
- });
740
- if (!res.ok) throw await parseError(res);
741
- return await res.json();
742
- }
743
- /** Fetch the durable history (change log) for one metadata item. */
744
- async history(type, name, options = {}) {
745
- const params = new URLSearchParams();
746
- if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
747
- if (options.limit !== void 0) params.set("limit", String(options.limit));
748
- const qs = params.toString();
749
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
750
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
751
- if (!res.ok) throw await parseError(res);
752
- return await res.json();
843
+ getCachedSync(key) {
844
+ const entry = this.cache.get(key);
845
+ if (!entry) return void 0;
846
+ if (this.ttl > 0 && Date.now() - entry.timestamp > this.ttl) {
847
+ this.cache.delete(key);
848
+ return void 0;
849
+ }
850
+ this.cache.delete(key);
851
+ this.cache.set(key, entry);
852
+ this.stats.hits++;
853
+ return entry.data;
753
854
  }
754
855
  /**
755
- * Fetch the protection-audit trail for one metadata item
756
- * (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
757
- * delete/reset attempt — allowed, denied, or forced — so the
758
- * Studio "审计日志 / Audit log" tab can render who tried what
759
- * and whether a lock blocked it.
856
+ * Check if a key exists in the cache (and is not expired)
857
+ *
858
+ * @param key - Cache key to check
859
+ * @returns true if the key exists and is not expired
760
860
  */
761
- async audit(type, name, options = {}) {
762
- const params = new URLSearchParams();
763
- if (options.limit !== void 0) params.set("limit", String(options.limit));
764
- const qs = params.toString();
765
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ""}`;
766
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
767
- if (!res.ok) throw await parseError(res);
768
- return await res.json();
861
+ has(key) {
862
+ const cached = this.cache.get(key);
863
+ if (!cached) {
864
+ return false;
865
+ }
866
+ const age = Date.now() - cached.timestamp;
867
+ if (age >= this.ttl) {
868
+ this.cache.delete(key);
869
+ return false;
870
+ }
871
+ return true;
769
872
  }
770
873
  };
771
874
 
@@ -956,91 +1059,6 @@ function isErrorType(error, errorClass) {
956
1059
  return error instanceof errorClass;
957
1060
  }
958
1061
 
959
- // src/cloud.ts
960
- var CloudOperations = class {
961
- constructor(getClient) {
962
- __publicField(this, "getClient", getClient);
963
- }
964
- /**
965
- * Deploy an application to the cloud.
966
- */
967
- async deploy(appId, config) {
968
- const client = this.getClient();
969
- const result = await client.cloud?.deploy?.(appId, config);
970
- return result ?? { deploymentId: `deploy-${Date.now()}`, status: "pending" };
971
- }
972
- /**
973
- * Get deployment status.
974
- */
975
- async getDeploymentStatus(deploymentId) {
976
- const client = this.getClient();
977
- const result = await client.cloud?.getDeployment?.(deploymentId);
978
- return result ?? { status: "unknown" };
979
- }
980
- /**
981
- * Search marketplace entries.
982
- */
983
- async searchMarketplace(query, category) {
984
- const client = this.getClient();
985
- const result = await client.cloud?.marketplace?.search?.({ query, category });
986
- return result?.items ?? [];
987
- }
988
- /**
989
- * Install a marketplace plugin.
990
- */
991
- async installPlugin(pluginId) {
992
- const client = this.getClient();
993
- const result = await client.cloud?.marketplace?.install?.(pluginId);
994
- return result ?? { success: false };
995
- }
996
- };
997
-
998
- // src/contracts.ts
999
- function validatePluginContract(contract) {
1000
- const errors = [];
1001
- const warnings = [];
1002
- if (!contract.name || contract.name.trim().length === 0) {
1003
- errors.push({ field: "name", message: "Plugin name is required", code: "MISSING_NAME" });
1004
- }
1005
- if (!contract.version || !/^\d+\.\d+\.\d+/.test(contract.version)) {
1006
- errors.push({ field: "version", message: "Valid semver version is required", code: "INVALID_VERSION" });
1007
- }
1008
- if (!contract.exports || contract.exports.length === 0) {
1009
- errors.push({ field: "exports", message: "At least one export is required", code: "NO_EXPORTS" });
1010
- }
1011
- if (contract.exports) {
1012
- const validTypes = ["component", "hook", "utility", "provider"];
1013
- for (const exp of contract.exports) {
1014
- if (!exp.name) {
1015
- errors.push({ field: "exports.name", message: "Export name is required", code: "MISSING_EXPORT_NAME" });
1016
- }
1017
- if (!validTypes.includes(exp.type)) {
1018
- errors.push({ field: "exports.type", message: `Invalid export type: ${exp.type}`, code: "INVALID_EXPORT_TYPE" });
1019
- }
1020
- }
1021
- }
1022
- if (!contract.permissions || contract.permissions.length === 0) {
1023
- warnings.push("No permissions declared \u2014 plugin will have minimal access");
1024
- }
1025
- return { valid: errors.length === 0, errors, warnings };
1026
- }
1027
- function generateContractManifest(contract) {
1028
- return {
1029
- $schema: "https://objectui.org/schemas/plugin-contract-v1.json",
1030
- name: contract.name,
1031
- version: contract.version,
1032
- peerDependencies: contract.peerDependencies ?? {},
1033
- exports: contract.exports.map((exp) => ({
1034
- name: exp.name,
1035
- type: exp.type,
1036
- description: exp.description ?? ""
1037
- })),
1038
- permissions: contract.permissions ?? [],
1039
- api: contract.api ?? {},
1040
- generatedAt: (/* @__PURE__ */ new Date()).toISOString()
1041
- };
1042
- }
1043
-
1044
1062
  // src/userState.ts
1045
1063
  var DEFAULT_RESOURCE = "sys_user_preference";
1046
1064
  function createObjectStackUserStateAdapter(options) {
@@ -1143,267 +1161,6 @@ function createObjectStackUserStateAdapter(options) {
1143
1161
  };
1144
1162
  }
1145
1163
 
1146
- // src/integration.ts
1147
- var IntegrationManager = class {
1148
- constructor() {
1149
- __publicField(this, "integrations", /* @__PURE__ */ new Map());
1150
- }
1151
- /**
1152
- * Register a new integration.
1153
- */
1154
- register(id, config) {
1155
- this.integrations.set(id, config);
1156
- }
1157
- /**
1158
- * Remove an integration.
1159
- */
1160
- unregister(id) {
1161
- this.integrations.delete(id);
1162
- }
1163
- /**
1164
- * Get all registered integrations.
1165
- */
1166
- getAll() {
1167
- return new Map(this.integrations);
1168
- }
1169
- /**
1170
- * Get integrations that match a specific event.
1171
- */
1172
- getForEvent(event) {
1173
- return Array.from(this.integrations.values()).filter(
1174
- (integration) => integration.enabled && integration.triggers?.some((t) => t.event === event)
1175
- );
1176
- }
1177
- /**
1178
- * Dispatch an event to all matching integrations.
1179
- * Returns results for each integration.
1180
- */
1181
- async dispatch(event, payload) {
1182
- const matching = this.getForEvent(event);
1183
- const results = [];
1184
- for (const [id, integration] of this.integrations) {
1185
- if (!matching.includes(integration)) continue;
1186
- try {
1187
- await this.send(integration, payload);
1188
- results.push({ id, success: true });
1189
- } catch (err) {
1190
- results.push({ id, success: false, error: err.message });
1191
- }
1192
- }
1193
- return results;
1194
- }
1195
- /**
1196
- * Send payload to a specific integration.
1197
- */
1198
- async send(integration, payload) {
1199
- switch (integration.provider) {
1200
- case "webhook": {
1201
- const cfg = integration.config;
1202
- const url = cfg.url;
1203
- try {
1204
- const parsed = new URL(url);
1205
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1206
- throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
1207
- }
1208
- } catch (e) {
1209
- if (e instanceof TypeError) {
1210
- throw new Error(`Invalid webhook URL: ${url}`);
1211
- }
1212
- throw e;
1213
- }
1214
- await fetch(url, {
1215
- method: cfg.method,
1216
- headers: {
1217
- "Content-Type": "application/json",
1218
- ...cfg.headers
1219
- },
1220
- body: JSON.stringify(payload)
1221
- });
1222
- break;
1223
- }
1224
- case "slack": {
1225
- const cfg = integration.config;
1226
- const url = cfg.webhookUrl;
1227
- try {
1228
- const parsed = new URL(url);
1229
- if (parsed.protocol !== "https:") {
1230
- throw new Error(`Invalid Slack webhook URL protocol: ${parsed.protocol}`);
1231
- }
1232
- } catch (e) {
1233
- if (e instanceof TypeError) {
1234
- throw new Error(`Invalid Slack webhook URL: ${url}`);
1235
- }
1236
- throw e;
1237
- }
1238
- await fetch(url, {
1239
- method: "POST",
1240
- headers: { "Content-Type": "application/json" },
1241
- body: JSON.stringify({
1242
- channel: cfg.channel,
1243
- username: cfg.username,
1244
- icon_emoji: cfg.iconEmoji,
1245
- text: JSON.stringify(payload)
1246
- })
1247
- });
1248
- break;
1249
- }
1250
- // Email and other providers would require server-side implementation
1251
- default:
1252
- break;
1253
- }
1254
- }
1255
- };
1256
-
1257
- // src/security.ts
1258
- var SecurityManager = class {
1259
- constructor(policy = {}) {
1260
- __publicField(this, "policy");
1261
- __publicField(this, "auditLog", []);
1262
- this.policy = policy;
1263
- }
1264
- /**
1265
- * Generate a CSP header string from the configuration.
1266
- */
1267
- generateCSPHeader() {
1268
- const csp = this.policy.csp;
1269
- if (!csp) return "";
1270
- const directives = [];
1271
- if (csp.scriptSrc?.length) directives.push(`script-src ${csp.scriptSrc.join(" ")}`);
1272
- if (csp.styleSrc?.length) directives.push(`style-src ${csp.styleSrc.join(" ")}`);
1273
- if (csp.imgSrc?.length) directives.push(`img-src ${csp.imgSrc.join(" ")}`);
1274
- if (csp.connectSrc?.length) directives.push(`connect-src ${csp.connectSrc.join(" ")}`);
1275
- if (csp.fontSrc?.length) directives.push(`font-src ${csp.fontSrc.join(" ")}`);
1276
- if (csp.frameSrc?.length) directives.push(`frame-src ${csp.frameSrc.join(" ")}`);
1277
- if (csp.reportUri) directives.push(`report-uri ${csp.reportUri}`);
1278
- return directives.join("; ");
1279
- }
1280
- /**
1281
- * Record an audit log entry.
1282
- */
1283
- recordAudit(entry) {
1284
- if (!this.policy.auditLog?.enabled) return;
1285
- if (this.policy.auditLog.events && !this.policy.auditLog.events.includes(entry.event)) return;
1286
- const fullEntry = {
1287
- ...entry,
1288
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1289
- };
1290
- this.auditLog.push(fullEntry);
1291
- const dest = this.policy.auditLog.destination ?? "console";
1292
- if (dest === "console" || dest === "both") {
1293
- console.info("[AUDIT]", fullEntry.event, fullEntry.userId, fullEntry.resource ?? "");
1294
- }
1295
- }
1296
- /**
1297
- * Get audit log entries.
1298
- */
1299
- getAuditLog(filter) {
1300
- let entries = [...this.auditLog];
1301
- if (filter?.event) entries = entries.filter((e) => e.event === filter.event);
1302
- if (filter?.userId) entries = entries.filter((e) => e.userId === filter.userId);
1303
- if (filter?.since) entries = entries.filter((e) => e.timestamp >= filter.since);
1304
- return entries;
1305
- }
1306
- /**
1307
- * Apply data masking to a record.
1308
- */
1309
- maskRecord(record, userRoles = []) {
1310
- if (!this.policy.dataMasking?.rules?.length) return record;
1311
- const masked = { ...record };
1312
- const maskChar = this.policy.dataMasking.maskChar ?? "*";
1313
- for (const rule of this.policy.dataMasking.rules) {
1314
- if (!(rule.field in masked) || masked[rule.field] == null) continue;
1315
- if (rule.exemptRoles?.some((role) => userRoles.includes(role))) continue;
1316
- const value = String(masked[rule.field]);
1317
- switch (rule.strategy) {
1318
- case "full":
1319
- masked[rule.field] = maskChar.repeat(value.length);
1320
- break;
1321
- case "partial": {
1322
- const visible = rule.visibleChars ?? 4;
1323
- if (value.length <= visible) {
1324
- masked[rule.field] = maskChar.repeat(value.length);
1325
- } else {
1326
- masked[rule.field] = value.slice(0, visible) + maskChar.repeat(value.length - visible);
1327
- }
1328
- break;
1329
- }
1330
- case "hash":
1331
- masked[rule.field] = `[HASHED:${simpleHash(value)}]`;
1332
- break;
1333
- case "redact":
1334
- masked[rule.field] = "[REDACTED]";
1335
- break;
1336
- }
1337
- }
1338
- return masked;
1339
- }
1340
- /**
1341
- * Update the security policy.
1342
- */
1343
- updatePolicy(policy) {
1344
- this.policy = { ...this.policy, ...policy };
1345
- }
1346
- /**
1347
- * Get current security policy.
1348
- */
1349
- getPolicy() {
1350
- return { ...this.policy };
1351
- }
1352
- };
1353
- function simpleHash(str) {
1354
- let hash = 0;
1355
- for (let i = 0; i < str.length; i++) {
1356
- const char = str.charCodeAt(i);
1357
- hash = (hash << 5) - hash + char;
1358
- hash = hash & hash;
1359
- }
1360
- return Math.abs(hash).toString(36);
1361
- }
1362
-
1363
- // src/studio.ts
1364
- function createDefaultCanvasConfig(overrides) {
1365
- return {
1366
- width: 1200,
1367
- height: 800,
1368
- background: "grid",
1369
- gridSize: 8,
1370
- snapToGrid: true,
1371
- zoom: {
1372
- min: 0.25,
1373
- max: 3,
1374
- step: 0.1,
1375
- current: 1
1376
- },
1377
- panOffset: { x: 0, y: 0 },
1378
- showMinimap: false,
1379
- minimapPosition: "bottom-right",
1380
- ...overrides
1381
- };
1382
- }
1383
- function snapToGrid(x, y, gridSize) {
1384
- return {
1385
- x: Math.round(x / gridSize) * gridSize,
1386
- y: Math.round(y / gridSize) * gridSize
1387
- };
1388
- }
1389
- function calculateAutoLayout(items, canvasWidth, padding = 40, gap = 40) {
1390
- const positions = [];
1391
- let currentX = padding;
1392
- let currentY = padding;
1393
- let rowMaxHeight = 0;
1394
- for (const item of items) {
1395
- if (currentX + item.width + padding > canvasWidth) {
1396
- currentX = padding;
1397
- currentY += rowMaxHeight + gap;
1398
- rowMaxHeight = 0;
1399
- }
1400
- positions.push({ id: item.id, x: currentX, y: currentY });
1401
- currentX += item.width + gap;
1402
- rowMaxHeight = Math.max(rowMaxHeight, item.height);
1403
- }
1404
- return positions;
1405
- }
1406
-
1407
1164
  // src/index.ts
1408
1165
  var FILTER_OPERATOR_ALIASES = {
1409
1166
  equals: "=",
@@ -1431,6 +1188,15 @@ var FILTER_OPERATOR_ALIASES = {
1431
1188
  notin: "nin",
1432
1189
  nin: "nin",
1433
1190
  contains: "contains",
1191
+ // Case-insensitive contains. A canonical `VIEW_FILTER_OPERATORS` member that
1192
+ // arrived with `@objectstack/spec` 17.1.0 (objectui#5328), and an IDENTITY row
1193
+ // like `contains` above: `icontains` is itself a member of
1194
+ // `VALID_AST_OPERATORS`, so the spelling the author writes is the spelling the
1195
+ // AST takes — nothing is translated and no case-sensitivity is lost.
1196
+ // Declared rather than left to the `?? op` fall-through on the rule this
1197
+ // table's test states: the AST gate accepting a spelling is not the driver
1198
+ // compiling it into a WHERE clause.
1199
+ icontains: "icontains",
1434
1200
  not_contains: "notcontains",
1435
1201
  notcontains: "notcontains",
1436
1202
  starts_with: "startswith",
@@ -1579,22 +1345,95 @@ function is404Error(error) {
1579
1345
  const code = typeof err.code === "string" ? err.code : "";
1580
1346
  return errorCodeIsAnyOf({ code }, ["OBJECT_NOT_FOUND", "RECORD_NOT_FOUND"]);
1581
1347
  }
1348
+ var API_ACCESS_DENIED_CODES = [
1349
+ "OBJECT_API_DISABLED",
1350
+ "OBJECT_API_METHOD_NOT_ALLOWED"
1351
+ ];
1352
+ function isApiAccessDeniedError(error) {
1353
+ return errorCodeIsAnyOf(error, API_ACCESS_DENIED_CODES);
1354
+ }
1355
+ var APP_PERMISSION_DENIED_CODE = "PERMISSION_DENIED";
1356
+ function isAppPermissionDeniedError(error) {
1357
+ return errorCodeIs(error, APP_PERMISSION_DENIED_CODE);
1358
+ }
1582
1359
  var AnalyticsNotInstalledError = class extends Error {
1583
- constructor(surface, detail) {
1360
+ constructor(surface, detail, serverCode) {
1584
1361
  super(
1585
1362
  `Analytics capability is not installed on this deployment \u2014 ${surface} is unavailable. Install @objectstack/service-analytics and mount AnalyticsServicePlugin to enable it.` + (detail ? ` (server said: ${detail})` : "")
1586
1363
  );
1587
1364
  __publicField(this, "code", "ANALYTICS_NOT_INSTALLED");
1588
1365
  /** The surface that was unavailable, for the message a host renders. */
1589
1366
  __publicField(this, "surface");
1367
+ /**
1368
+ * The server's own ADR-0112 code — the field this branch was chosen BY, when
1369
+ * a producer named one (`NOT_IMPLEMENTED` from the mounted route with no
1370
+ * service behind it, `ROUTE_NOT_FOUND` from the dispatcher when the route is
1371
+ * not mounted at all). Absent for the no-code residual, where a bare
1372
+ * transport 404/501 is all there was to read.
1373
+ *
1374
+ * Additive (objectui#5663): carried so a reader can audit that the headline
1375
+ * and the quoted `detail` below came off the same answer.
1376
+ */
1377
+ __publicField(this, "serverCode");
1590
1378
  this.name = "AnalyticsNotInstalledError";
1591
1379
  this.surface = surface;
1380
+ this.serverCode = serverCode;
1592
1381
  }
1593
1382
  };
1594
1383
  function isAnalyticsNotInstalledError(error) {
1595
1384
  if (!error || typeof error !== "object") return false;
1596
1385
  return error.code === "ANALYTICS_NOT_INSTALLED";
1597
1386
  }
1387
+ var AnalyticsDatasetNotFoundError = class extends Error {
1388
+ constructor(opts) {
1389
+ const subject = opts.datasetName ? `Dataset "${opts.datasetName}"` : "The requested dataset";
1390
+ super(
1391
+ `${subject} is not defined in this environment \u2014 the app that defines it is missing or out of date. Analytics itself is installed and answering, so the fix is to upgrade the installed app, not to install a server plugin.` + (opts.serverMessage ? ` (server said: ${opts.serverMessage})` : "")
1392
+ );
1393
+ __publicField(this, "code", "ANALYTICS_DATASET_NOT_FOUND");
1394
+ /**
1395
+ * The dataset this query asked for, taken from the REQUEST rather than parsed
1396
+ * back out of the server's prose: the request is what we know for certain,
1397
+ * and re-reading the name out of a message would make the headline depend on
1398
+ * that message's WORDING — the coupling framework#5367 spent a whole issue
1399
+ * removing from the producing route.
1400
+ */
1401
+ __publicField(this, "datasetName");
1402
+ /** The server's own ADR-0112 code — the field this branch was chosen BY. */
1403
+ __publicField(this, "serverCode");
1404
+ /** The server's own message, quoted verbatim in the parenthetical. */
1405
+ __publicField(this, "serverMessage");
1406
+ this.name = "AnalyticsDatasetNotFoundError";
1407
+ this.datasetName = opts.datasetName;
1408
+ this.serverCode = opts.serverCode;
1409
+ this.serverMessage = opts.serverMessage;
1410
+ }
1411
+ };
1412
+ var AnalyticsUnauthenticatedError = class extends Error {
1413
+ constructor(opts = {}) {
1414
+ super(
1415
+ "Analytics query refused: this session is not authenticated. Sign in again and retry \u2014 the request was refused before it ran, so it says nothing about whether the analytics capability is installed." + (opts.serverMessage ? ` (server said: ${opts.serverMessage})` : "")
1416
+ );
1417
+ __publicField(this, "code", "ANALYTICS_UNAUTHENTICATED");
1418
+ /** The server's own ADR-0112 code — the field this branch was chosen BY. */
1419
+ __publicField(this, "serverCode");
1420
+ /** The server's own message, quoted verbatim in the parenthetical. */
1421
+ __publicField(this, "serverMessage");
1422
+ this.name = "AnalyticsUnauthenticatedError";
1423
+ this.serverCode = opts.serverCode;
1424
+ this.serverMessage = opts.serverMessage;
1425
+ }
1426
+ };
1427
+ function readAnalyticsErrorEnvelope(body) {
1428
+ if (!body || typeof body !== "object") return {};
1429
+ const flat = body;
1430
+ const wrapped = flat.error && typeof flat.error === "object" ? flat.error : void 0;
1431
+ const source = wrapped ?? flat;
1432
+ const code = typeof source.code === "string" && source.code.length > 0 ? source.code : void 0;
1433
+ const message = typeof source.message === "string" && source.message.length > 0 ? source.message : typeof flat.error === "string" && flat.error.length > 0 ? flat.error : void 0;
1434
+ return { code, message };
1435
+ }
1436
+ var ANALYTICS_ABSENT_STATUSES = [404, 501];
1598
1437
  var AnalyticsQueryRejectedError = class extends Error {
1599
1438
  constructor(detail, serverCode) {
1600
1439
  super(
@@ -1609,13 +1448,22 @@ var AnalyticsQueryRejectedError = class extends Error {
1609
1448
  };
1610
1449
  function classifyAnalyticsFailure(error) {
1611
1450
  const err = error ?? {};
1612
- const code = typeof err.code === "string" ? err.code : void 0;
1451
+ const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
1613
1452
  const message = typeof err.message === "string" ? err.message : void 0;
1614
1453
  const status = typeof err.httpStatus === "number" ? err.httpStatus : typeof err.status === "number" ? err.status : typeof err.statusCode === "number" ? err.statusCode : void 0;
1615
- if (code === "VALIDATION_FAILED" || status === 400) return { kind: "rejected", code, message };
1616
- if (status === 404 || status === 501 || code === "NOT_IMPLEMENTED" || code === "ROUTE_NOT_FOUND") {
1454
+ if (errorCodeIsAnyOf({ code }, ["NOT_IMPLEMENTED", "ROUTE_NOT_FOUND"])) {
1617
1455
  return { kind: "not-installed", code, message };
1618
1456
  }
1457
+ if (errorCodeIs({ code }, "VALIDATION_FAILED")) return { kind: "rejected", code, message };
1458
+ if (errorCodeIs({ code }, "UNAUTHENTICATED")) return { kind: "unauthenticated", code, message };
1459
+ if (errorCodeIs({ code }, "CUBE_NOT_FOUND")) return { kind: "cube-not-found", code, message };
1460
+ if (code === void 0) {
1461
+ if (status !== void 0 && ANALYTICS_ABSENT_STATUSES.includes(status)) {
1462
+ return { kind: "not-installed", code, message };
1463
+ }
1464
+ if (status === 400) return { kind: "rejected", code, message };
1465
+ if (status === 401) return { kind: "unauthenticated", code, message };
1466
+ }
1619
1467
  return { kind: "unknown", code, message };
1620
1468
  }
1621
1469
  var ConcurrentUpdateError = class extends Error {
@@ -1663,6 +1511,19 @@ function normaliseClientError(error) {
1663
1511
  message: typeof e.message === "string" ? e.message : void 0
1664
1512
  });
1665
1513
  }
1514
+ function formatHttpFailureMessage(message, meta) {
1515
+ if (!meta || typeof meta !== "object") return null;
1516
+ const method = typeof meta.method === "string" && meta.method ? meta.method : void 0;
1517
+ const url = typeof meta.url === "string" && meta.url ? meta.url : void 0;
1518
+ const status = typeof meta.status === "number" ? meta.status : typeof meta.statusCode === "number" ? meta.statusCode : void 0;
1519
+ if (!method && !url && status === void 0) return null;
1520
+ const errBody = meta.error && typeof meta.error === "object" ? meta.error : void 0;
1521
+ const rawCode = typeof meta.code === "string" && meta.code || (errBody && typeof errBody.code === "string" ? errBody.code : void 0);
1522
+ const parts = [method ?? "GET", url ?? "<unknown url>"];
1523
+ parts.push(`-> ${status ?? "no status"}`);
1524
+ if (rawCode) parts.push(`[${rawCode}]`);
1525
+ return `${message}: ${parts.join(" ")}`;
1526
+ }
1666
1527
  function createQuietHttpLogger() {
1667
1528
  const isExpected404 = (meta) => {
1668
1529
  if (!meta || typeof meta !== "object") return false;
@@ -1675,17 +1536,27 @@ function createQuietHttpLogger() {
1675
1536
  return false;
1676
1537
  };
1677
1538
  const logger = {
1678
- debug: (message, meta) => console.debug(message, meta ?? ""),
1679
- info: (message, meta) => console.info(message, meta ?? ""),
1539
+ debug: (message, meta) => (
1540
+ // eslint-disable-next-line no-console -- objectui#4029, see comment above
1541
+ console.debug(message, meta ?? "")
1542
+ ),
1543
+ info: (message, meta) => (
1544
+ // eslint-disable-next-line no-console -- objectui#4029, see comment above
1545
+ console.info(message, meta ?? "")
1546
+ ),
1680
1547
  warn: (message, meta) => console.warn(message, meta ?? ""),
1681
1548
  error: (message, error, meta) => {
1682
1549
  if (isExpected404(meta)) {
1683
- console.debug(`[ObjectStack] ${message} (suppressed expected 404)`, meta);
1550
+ console.debug(
1551
+ `[ObjectStack] ${formatHttpFailureMessage(message, meta) ?? message} (suppressed expected 404)`,
1552
+ meta
1553
+ );
1684
1554
  return;
1685
1555
  }
1686
- console.error(message, error ?? "", meta ?? "");
1556
+ console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? "", meta ?? "");
1687
1557
  },
1688
- fatal: (message, error, meta) => console.error(message, error ?? "", meta ?? ""),
1558
+ fatal: (message, error, meta) => console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? "", meta ?? ""),
1559
+ // eslint-disable-next-line no-console -- objectui#4029, see comment above
1689
1560
  log: (message, ...args) => console.log(message, ...args),
1690
1561
  child: () => logger,
1691
1562
  withTrace: () => logger
@@ -1724,6 +1595,73 @@ function withoutNoOpDrops(droppedFields, sent, stored) {
1724
1595
  }
1725
1596
  return out;
1726
1597
  }
1598
+ function viewItemObjectName(item) {
1599
+ const spec = item?.list ?? item;
1600
+ return spec?.data?.object ?? spec?.object ?? spec?.objectName;
1601
+ }
1602
+ var VIEW_OVERLAY_MARKER = "_isOverride";
1603
+ function isLegacyOverlayRow(item, spec) {
1604
+ if (spec && spec.config && typeof spec.config === "object") return false;
1605
+ const viewKind = item?.viewKind ?? spec?.viewKind;
1606
+ return viewKind === "list";
1607
+ }
1608
+ function isPersonalizationOverlayRow(item, spec) {
1609
+ if (item?.[VIEW_OVERLAY_MARKER] === true) return true;
1610
+ if (spec?.[VIEW_OVERLAY_MARKER] === true) return true;
1611
+ return isLegacyOverlayRow(item, spec);
1612
+ }
1613
+ var VIEW_OVERLAY_OWNED_KEYS = Object.freeze([
1614
+ "rowHeight",
1615
+ "sort",
1616
+ "hiddenFields",
1617
+ "columnState",
1618
+ "inlineEdit"
1619
+ ]);
1620
+ var VIEW_OVERLAY_IDENTITY_KEYS = Object.freeze([
1621
+ "name",
1622
+ "object",
1623
+ "viewKind",
1624
+ VIEW_OVERLAY_MARKER
1625
+ ]);
1626
+ function narrowPersonalizationOverlay(row) {
1627
+ if (!row || typeof row !== "object" || Array.isArray(row)) return row;
1628
+ const item = row;
1629
+ const spec = item.list ?? item;
1630
+ if (!isPersonalizationOverlayRow(item, spec)) return row;
1631
+ const narrowed = {};
1632
+ for (const key of VIEW_OVERLAY_IDENTITY_KEYS) {
1633
+ if (item[key] !== void 0) narrowed[key] = item[key];
1634
+ }
1635
+ for (const key of VIEW_OVERLAY_OWNED_KEYS) {
1636
+ if (item[key] !== void 0) narrowed[key] = item[key];
1637
+ }
1638
+ return narrowed;
1639
+ }
1640
+ function unwrapViewDraft(resp) {
1641
+ if (!resp || typeof resp !== "object") return null;
1642
+ const env = resp;
1643
+ const body = "item" in env ? env.item : env;
1644
+ if (!body || typeof body !== "object") return null;
1645
+ const spec = body.list ?? body;
1646
+ if (!spec || typeof spec !== "object") return null;
1647
+ return Object.keys(spec).length > 0 ? spec : null;
1648
+ }
1649
+ function readViewDeleteReceipt(result) {
1650
+ const r = result ?? void 0;
1651
+ return {
1652
+ removed: !!(r?.deleted ?? r?.reset ?? true),
1653
+ ...typeof r?.reset === "boolean" ? { reset: r.reset } : {},
1654
+ ...typeof r?.message === "string" ? { message: r.message } : {}
1655
+ };
1656
+ }
1657
+ function mergeViewPatch(current, partial, viewName, objectName) {
1658
+ return {
1659
+ ...current,
1660
+ ...partial,
1661
+ name: viewName,
1662
+ object: current?.object || current?.data?.object || objectName
1663
+ };
1664
+ }
1727
1665
  var ObjectStackAdapter = class {
1728
1666
  constructor(config) {
1729
1667
  __publicField(this, "client");
@@ -1776,7 +1714,14 @@ var ObjectStackAdapter = class {
1776
1714
  // whose response carried `droppedFields` (framework #3431/#3455) so the app
1777
1715
  // shell can surface a toast instead of the strip passing silently.
1778
1716
  __publicField(this, "writeWarningListeners", /* @__PURE__ */ new Set());
1717
+ // Subscribers registered via onSaveAdvisory(). Emitted after a metadata save
1718
+ // through THIS adapter's `ObjectStackClient` whose 200 carried a non-empty
1719
+ // `advisories` array (#4237; backend objectstack#7435). Sibling of the set
1720
+ // above in every respect except which door produced the event: that one is
1721
+ // record CRUD, this one is the metadata save door.
1722
+ __publicField(this, "saveAdvisoryListeners", /* @__PURE__ */ new Set());
1779
1723
  this.client = new ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
1724
+ this.installSaveAdvisoryInterceptor();
1780
1725
  this.metadataCache = new MetadataCache(config.cache);
1781
1726
  this.autoReconnect = config.autoReconnect ?? true;
1782
1727
  this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3;
@@ -1931,7 +1876,7 @@ var ObjectStackAdapter = class {
1931
1876
  const result = await this.client.data.find(resource, queryOptions);
1932
1877
  return this.normalizeQueryResult(result, params);
1933
1878
  } catch (err) {
1934
- if (is404Error(err)) {
1879
+ if (!isApiAccessDeniedError(err) && is404Error(err)) {
1935
1880
  this.missingResources.add(resource);
1936
1881
  return { data: [], total: 0 };
1937
1882
  }
@@ -2159,6 +2104,115 @@ var ObjectStackAdapter = class {
2159
2104
  this.writeWarningListeners.delete(callback);
2160
2105
  };
2161
2106
  }
2107
+ /**
2108
+ * Subscribe to metadata save-advisory events — the runtime authoring gate's
2109
+ * advisory findings on a save that SUCCEEDED (#4237; backend
2110
+ * objectstack#7435). Returns an unsubscribe function.
2111
+ *
2112
+ * Deliberately the same seam as {@link onWriteWarning} (#3431/#3455), which
2113
+ * is what {@link MetadataSaveAdvisoryEvent}'s own declaration already said it
2114
+ * was modelled on. It is a SIBLING of that channel rather than a second
2115
+ * payload pushed down it: `WriteWarningEvent` is a closed shape whose
2116
+ * `droppedFields` is required and means "fields the write legally stripped",
2117
+ * so carrying advisories on it would either force every existing
2118
+ * `onWriteWarning` consumer to grow a branch or make the event lie about what
2119
+ * happened. The seam's SHAPE is what is reused here — a long-lived instance
2120
+ * with a `subscribe → unsubscribe` registration that `AdapterProvider` wires
2121
+ * once — not its event type.
2122
+ *
2123
+ * Why here and not on the config, which is how the other client class does it
2124
+ * (#4133/#4236): `MetadataClient` is minted per component by
2125
+ * `useMetadataClient`, so it has no instance to subscribe to and its sink
2126
+ * rides the factory. `ObjectStackAdapter` is the opposite — one long-lived
2127
+ * instance per app, already carrying this exact subscription pattern.
2128
+ */
2129
+ onSaveAdvisory(callback) {
2130
+ this.saveAdvisoryListeners.add(callback);
2131
+ return () => {
2132
+ this.saveAdvisoryListeners.delete(callback);
2133
+ };
2134
+ }
2135
+ /**
2136
+ * Notify all save-advisory subscribers. Isolated exactly like
2137
+ * {@link emitWriteWarning}: a throwing listener must neither break the save
2138
+ * nor starve the others.
2139
+ */
2140
+ emitSaveAdvisory(event) {
2141
+ for (const listener of this.saveAdvisoryListeners) {
2142
+ try {
2143
+ listener(event);
2144
+ } catch (err) {
2145
+ console.warn("ObjectStackAdapter: save-advisory listener error", err);
2146
+ }
2147
+ }
2148
+ }
2149
+ /**
2150
+ * Install the ONE emitter for the metadata save door (#4237).
2151
+ *
2152
+ * ## Why this seam, and what it covers
2153
+ *
2154
+ * `ObjectStackClient.meta.saveItem` is the second client class that writes
2155
+ * through `PUT /api/v1/meta/:type/:name`, and every one of its callers reaches
2156
+ * it through an adapter this class constructed — the four inside this file
2157
+ * (`updateViewConfig`, the two view paths, `updateDashboard`) via
2158
+ * `this.client`, and every caller outside it via {@link getClient}, which
2159
+ * hands back this same instance: `MetadataService` (app-shell, five saves),
2160
+ * `useNavigationSync`, and plugin-designer's Create/EditAppPage. Wrapping the
2161
+ * method once here therefore covers all of them WITHOUT a per-site edit, which
2162
+ * is the whole point — a toast copied into a dozen call sites is the shape
2163
+ * #4133 rejected for the other client class and it is no better here.
2164
+ *
2165
+ * `meta` is an own, writable property assigned per instance in the SDK's
2166
+ * constructor (`this.meta = { … }`), and the client this adapter builds is
2167
+ * never shared, so the wrap is bounded to an object this adapter owns for its
2168
+ * whole lifetime. It is not a prototype or global patch.
2169
+ *
2170
+ * ## Response shape — measured, not assumed
2171
+ *
2172
+ * The two client classes' envelopes coincide at the top level, which is what
2173
+ * makes `readSaveAdvisories` reusable unchanged across both. `SaveMetaItem-
2174
+ * ResponseSchema` puts `advisories` at the body's top level next to
2175
+ * `success` / `version` / `seq` / `state`, and the SDK's `unwrapResponse`
2176
+ * strips its `{ success, data }` envelope only when the body actually HAS a
2177
+ * `data` key — this body does not, so it is returned verbatim. So the same
2178
+ * reader that `MetadataClient.save` uses reads this response correctly, and
2179
+ * the pins in `onSaveAdvisory.test.ts` drive a real SDK client through a fake
2180
+ * `fetch` rather than stubbing `meta`, so that continues to be measured.
2181
+ *
2182
+ * ## Draft-door honesty (D1)
2183
+ *
2184
+ * Drafts are NEVER gated: the framework returns at its D1 early-return
2185
+ * (`if (args.state !== 'active') return null`) before running a rule, so a
2186
+ * draft save produces no findings to withhold. This client class has no draft
2187
+ * door at all to worry about — the SDK's `saveItem(type, name, item)` takes no
2188
+ * mode and always writes the active door, which is exactly why the gate DOES
2189
+ * run for its callers. `mode` on the emitted event is therefore derived from
2190
+ * the response's own `state` rather than from a request-side flag that does
2191
+ * not exist here: `'draft'` when the server says the row landed as a draft,
2192
+ * `'publish'` otherwise. That keeps the event truthful about which door it
2193
+ * came through instead of hard-coding one.
2194
+ */
2195
+ installSaveAdvisoryInterceptor() {
2196
+ const meta = this.client.meta;
2197
+ const original = meta.saveItem.bind(meta);
2198
+ meta.saveItem = async (type, name, item) => {
2199
+ const result = await original(type, name, item);
2200
+ try {
2201
+ const advisories = readSaveAdvisories(result);
2202
+ if (advisories.length > 0) {
2203
+ this.emitSaveAdvisory({
2204
+ type,
2205
+ name,
2206
+ mode: result?.state === "draft" ? "draft" : "publish",
2207
+ advisories
2208
+ });
2209
+ }
2210
+ } catch (err) {
2211
+ console.warn("ObjectStackAdapter: save-advisory read error", err);
2212
+ }
2213
+ return result;
2214
+ };
2215
+ }
2162
2216
  async create(resource, data) {
2163
2217
  await this.connect();
2164
2218
  try {
@@ -2218,10 +2272,10 @@ var ObjectStackAdapter = class {
2218
2272
  String(id),
2219
2273
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
2220
2274
  );
2221
- if (result.deleted) {
2275
+ if (result.success) {
2222
2276
  this.emitMutation({ type: "delete", resource, id });
2223
2277
  }
2224
- return result.deleted;
2278
+ return result.success;
2225
2279
  } catch (err) {
2226
2280
  throw normaliseClientError(err);
2227
2281
  }
@@ -2764,6 +2818,8 @@ var ObjectStackAdapter = class {
2764
2818
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
2765
2819
  const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
2766
2820
  err.status = res.status;
2821
+ err.code = errorBody?.code ?? errorBody?.error?.code;
2822
+ err.httpStatus = res.status;
2767
2823
  throw err;
2768
2824
  }
2769
2825
  const body = await res.json();
@@ -3022,42 +3078,109 @@ var ObjectStackAdapter = class {
3022
3078
  return null;
3023
3079
  }
3024
3080
  }
3081
+ /**
3082
+ * **The one place that knows which cache keys a view-row write invalidates.**
3083
+ *
3084
+ * Two reads are cached under view-shaped keys, and both go stale when any
3085
+ * writer touches a view row for `objectName`:
3086
+ *
3087
+ * | reader | key |
3088
+ * |----------------------------|----------------------------|
3089
+ * | {@link getView} | `view:{object}:{viewName}` |
3090
+ * | {@link listViewOverrides} | `view-overrides:{object}` |
3091
+ *
3092
+ * Every writer routes here rather than restating that pair. The repo has now
3093
+ * paid twice for restatement: objectui#3778 removed five copies of a key
3094
+ * (`views:{object}`) that no reader had ever populated, and objectui#4363
3095
+ * fixed four copies that named only half the live set — and objectui#4373 is
3096
+ * the measured proof that the failure recurs by default, because the console's
3097
+ * real create/publish flow writes these same rows through the ADR-0034
3098
+ * metadata seam and never learned the key list at all. A pin suite can only
3099
+ * guard the writers that exist; a mandatory seam makes the next writer
3100
+ * structurally unable to forget.
3101
+ *
3102
+ * The rule is uniform per WRITE, not per branch: a write to a view row drops
3103
+ * both keys, in that order. Draft-addressed writes over-invalidate on purpose
3104
+ * — both readers enumerate PUBLISHED rows, so a draft write stales neither —
3105
+ * because the costs are not symmetric. An unnecessary invalidation costs one
3106
+ * refetch; a missed one costs up to the cache's 5-minute TTL of stale
3107
+ * overrides, and "which half am I in?" is not a question a future writer
3108
+ * should have to re-answer to stay correct.
3109
+ *
3110
+ * Public because the writers are not all in this class: app-shell's ADR-0034
3111
+ * seam (`runtime-metadata-persistence.ts`) and `MetadataService` write the
3112
+ * same rows through `client.meta` / the `/meta` draft-publish API, and they
3113
+ * reach this adapter as the `dataSource` they were already handed.
3114
+ *
3115
+ * @param objectName - Object the view belongs to (the `{object}` in both keys)
3116
+ * @param viewName - The view's canonical `name` — the identity
3117
+ * {@link getView} reads back by and {@link listViewOverrides} keys its map
3118
+ * with. Callers that hold a qualified `<object>.<key>` name pass it as-is:
3119
+ * it is the row key, not a path to be split.
3120
+ */
3121
+ invalidateViewKeys(objectName, viewName) {
3122
+ this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
3123
+ this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
3124
+ }
3025
3125
  /**
3026
3126
  * Batch-fetch all persisted view overrides for an object.
3027
3127
  *
3028
- * Per-view runtime overrides (density, column widths, sort, …) are
3029
- * stored in the metadata registry under key `<objectName>/<viewName>`.
3030
- * Loading them per-view fires N HTTP GETs that return 404 for views
3031
- * the user has never customized — generates console noise on every
3032
- * page load. This batch method performs a single
3033
- * `GET /api/v1/meta/<objectName>` (returns `{type, items}`) and
3034
- * returns a `{viewName: override}` map. Returns an empty map if the
3035
- * server doesn't expose the listing or returns no items.
3128
+ * Per-view runtime overrides (density, column widths, sort, hidden
3129
+ * columns, inlineEdit …) live in the SAME metadata namespace the
3130
+ * write path uses: `type='view'`, `name=<viewId>` (see
3131
+ * {@link updateViewConfig}). Loading them per-view fires N HTTP GETs
3132
+ * that 404 for every view the user never customized — console noise on
3133
+ * every page load. This batch method performs a single
3134
+ * `GET /api/v1/meta/view` (returns `{type, items}`) and narrows the
3135
+ * result to `objectName` client-side, exactly as {@link listViews}
3136
+ * does over the same rows (shared accessor: {@link viewItemObjectName}).
3036
3137
  *
3037
- * Result is cached identically to {@link getView}; saving a view via
3038
- * {@link updateViewConfig} invalidates the cache.
3138
+ * objectui#3774 — this used to enumerate `GET /api/v1/meta/<objectName>`,
3139
+ * putting the OBJECT name in the metadata TYPE slot. That key space is
3140
+ * disjoint from the one the write path lands in, so the batch map came
3141
+ * back empty for every object, forever, and every saved personalization
3142
+ * read back as "setting didn't save".
3143
+ *
3144
+ * FAILURES REJECT — they are not answered as `{}`. An empty map is an
3145
+ * authoritative "this object has no overrides" (callers may trust it and
3146
+ * skip the per-view reads); a transport/permission failure is "I could
3147
+ * not tell", and reporting the two as the same value is what made
3148
+ * ObjectView's per-view {@link getView} fallback unreachable code. When
3149
+ * we cannot tell, we do not pretend we can. Rejections are not cached
3150
+ * (the cache stores on success only), so a transient failure does not
3151
+ * pin an empty answer for the TTL.
3152
+ *
3153
+ * Result is cached identically to {@link getView}, and EVERY view write —
3154
+ * on this adapter and above it — invalidates it through the one seam,
3155
+ * {@link invalidateViewKeys}. For a long time only {@link updateViewConfig}
3156
+ * did (objectui#4363), which left the other three adapter paths stale
3157
+ * for the cache's 5-minute TTL. That gap does not self-heal: the consumer
3158
+ * (`loadViewOverrides`, app-shell `ObjectView`) treats a RESOLVED map as
3159
+ * authoritative and deliberately does not re-probe per view — objectui#3774,
3160
+ * and correct, since re-probing reinstates the 404 flurry the batch read
3161
+ * exists to remove. So a stale map here is served in full, and the per-view
3162
+ * {@link getView} fallback that would have masked it never runs.
3039
3163
  *
3040
3164
  * @param objectName - Object name (e.g. 'lead')
3041
3165
  * @returns Map keyed by view name with the persisted override config
3166
+ * @throws whatever the metadata transport throws — callers that have a
3167
+ * per-view fallback should catch and use it.
3042
3168
  */
3043
3169
  async listViewOverrides(objectName) {
3044
3170
  await this.connect();
3045
- try {
3046
- const cacheKey = `view-overrides:${objectName}`;
3047
- return await this.metadataCache.get(cacheKey, async () => {
3048
- const result = await this.client.meta.getItems(objectName);
3049
- const items = Array.isArray(result?.items) ? result.items : [];
3050
- const out = {};
3051
- for (const it of items) {
3052
- if (!it || typeof it !== "object") continue;
3053
- const key = it.name ?? it.id ?? it._name;
3054
- if (typeof key === "string" && key) out[key] = it;
3055
- }
3056
- return out;
3057
- });
3058
- } catch {
3059
- return {};
3060
- }
3171
+ const cacheKey = `view-overrides:${objectName}`;
3172
+ return await this.metadataCache.get(cacheKey, async () => {
3173
+ const result = await this.client.meta.getItems("view");
3174
+ const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
3175
+ const out = {};
3176
+ for (const it of items) {
3177
+ if (!it || typeof it !== "object") continue;
3178
+ if (viewItemObjectName(it) !== objectName) continue;
3179
+ const key = it.name;
3180
+ if (typeof key === "string" && key) out[key] = it;
3181
+ }
3182
+ return out;
3183
+ });
3061
3184
  }
3062
3185
  /**
3063
3186
  * Get a view definition for an object.
@@ -3083,34 +3206,65 @@ var ObjectStackAdapter = class {
3083
3206
  }
3084
3207
  }
3085
3208
  /**
3086
- * Persist a view definition for an object.
3209
+ * Persist a toolbar-driven view config patch — density, column widths,
3210
+ * sort, hidden columns, inline edit. Symmetric counterpart to
3211
+ * {@link getView}: writes the row to the server metadata store via
3212
+ * `client.meta.saveItem`, then invalidates the matching cache entry so the
3213
+ * next {@link getView} reflects the new payload. Returns the persisted item
3214
+ * when the server echoes it, otherwise undefined.
3215
+ *
3216
+ * Called from exactly ONE production site — `ObjectView`'s
3217
+ * `persistViewPatch`, for the toolbar toggle — but that ONE call site
3218
+ * fires for BOTH kinds of active tab: a code-defined **system** view (no
3219
+ * row of its own yet) and a genuinely user-created **saved** view (already
3220
+ * has a row — the toggle is editing ITS OWN definition, not laying an
3221
+ * overlay on top of it). Which one a given call means is NOT re-derived
3222
+ * here from the write's shape (objectui#4227's own lesson: shape inference
3223
+ * on this namespace is exactly what let a system view masquerade as
3224
+ * saved) — the caller already knows, via the same `isSavedViewId`
3225
+ * classification that gates the switcher's readonly flag and its five
3226
+ * mutating handlers, and passes it as {@link opts.isSavedView}.
3087
3227
  *
3088
- * Symmetric counterpart to {@link getView}: writes the view to the
3089
- * server metadata store via `client.meta.saveItem`, then invalidates
3090
- * the matching cache entry so the next {@link getView} reflects the
3091
- * new payload. Returns the persisted item when the server echoes it,
3092
- * otherwise undefined.
3228
+ * - `isSavedView` false/omitted (system-view target, the common case and
3229
+ * the default for backward compatibility): stamps
3230
+ * {@link VIEW_OVERLAY_MARKER} so {@link listViews} excludes the row —
3231
+ * the original objectui#4227 fix.
3232
+ * - `isSavedView` true: the marker is withheld. Stamping it here would
3233
+ * flag the saved view's OWN row as a personalization overlay, and
3234
+ * `listViews()` would exclude it on the very next read — the user's own
3235
+ * view would vanish from the switcher the moment they toggled its
3236
+ * density (objectui#4227 follow-up, PM review on PR #4713, measured:
3237
+ * `persistViewPatch` has no gate on which kind of tab is active, and
3238
+ * this method writes to the exact same `(type='view', name=viewId)` key
3239
+ * {@link createView}/the ADR-0034 `viewEnvelope` seam already used for
3240
+ * that view, so the write is an upsert onto the saved view's row, not a
3241
+ * new one).
3093
3242
  *
3094
- * Used by ObjectView for "live" toolbar persistence (density,
3095
- * column widths, sort, etc.) and by the View Config Panel for
3096
- * explicit saves.
3243
+ * Per objectstack#7494's ruling, the overlay this writes (system-view
3244
+ * case) is ORG-WIDE shared view settings, not a per-user preference — a
3245
+ * true per-user scope is a parked v18 direction on the platform side.
3097
3246
  *
3098
3247
  * @param objectName - Object name (e.g. 'lead')
3099
3248
  * @param viewId - View identifier (e.g. 'all_leads')
3100
3249
  * @param config - Full view definition to persist
3250
+ * @param opts.isSavedView - Whether `viewId` already names a saved view
3251
+ * (vs. a system view being personalized for the first time). Omit /
3252
+ * `false` for the default overlay-marking behavior.
3101
3253
  */
3102
- async updateViewConfig(objectName, viewId, config) {
3254
+ async updateViewConfig(objectName, viewId, config, opts) {
3103
3255
  await this.connect();
3104
- const merged = { ...config || {}, object: config?.object || objectName, name: viewId };
3256
+ const merged = {
3257
+ ...config || {},
3258
+ object: config?.object || objectName,
3259
+ name: viewId,
3260
+ ...opts?.isSavedView ? {} : { [VIEW_OVERLAY_MARKER]: true }
3261
+ };
3105
3262
  const result = await this.client.meta.saveItem(
3106
3263
  "view",
3107
3264
  viewId,
3108
3265
  merged
3109
3266
  );
3110
- const cacheKey = `view:${objectName}:${viewId}`;
3111
- this.metadataCache.invalidate?.(cacheKey);
3112
- this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
3113
- this.metadataCache.invalidate?.(`views:${objectName}`);
3267
+ this.invalidateViewKeys(objectName, viewId);
3114
3268
  if (result && result.item) return result.item;
3115
3269
  return result ?? void 0;
3116
3270
  }
@@ -3121,8 +3275,10 @@ var ObjectStackAdapter = class {
3121
3275
  * match the view spec shape.
3122
3276
  *
3123
3277
  * Returns view spec objects with their canonical `name` as identifier.
3124
- * Filters by `data.object === objectName` (or top-level `object`)
3125
- * client-side because the metadata index is name-only, not field-typed.
3278
+ * Narrows to one object client-side via {@link viewItemObjectName} —
3279
+ * the metadata index is name-only, not field-typed, so the route has no
3280
+ * `?object=` to push the filter down into. {@link listViewOverrides}
3281
+ * reads the same rows through the same accessor.
3126
3282
  */
3127
3283
  async listViews(objectName, options) {
3128
3284
  await this.connect();
@@ -3139,10 +3295,11 @@ var ObjectStackAdapter = class {
3139
3295
  return items.filter((v) => {
3140
3296
  if (!v) return false;
3141
3297
  const spec = v.list ?? v;
3142
- const obj = spec?.data?.object ?? spec?.object ?? spec?.objectName;
3143
- if (obj !== objectName) return false;
3298
+ if (viewItemObjectName(v) !== objectName) return false;
3144
3299
  const viewKind = v.viewKind ?? spec?.viewKind;
3145
- return !(viewKind && FORM_FAMILY.has(viewKind));
3300
+ if (viewKind && FORM_FAMILY.has(viewKind)) return false;
3301
+ if (isPersonalizationOverlayRow(v, spec)) return false;
3302
+ return true;
3146
3303
  }).map((v) => {
3147
3304
  const spec = v.list ?? v;
3148
3305
  const isDraft = v._draft === true || spec?._draft === true;
@@ -3201,6 +3358,10 @@ var ObjectStackAdapter = class {
3201
3358
  *
3202
3359
  * Generates a snake_case name if `spec.name` is not provided by appending
3203
3360
  * a short timestamp suffix to the source-name hint.
3361
+ *
3362
+ * Invalidates through {@link invalidateViewKeys}, like every other writer —
3363
+ * see {@link listViewOverrides} for why the batch map is the one that cannot
3364
+ * heal itself (objectui#4363).
3204
3365
  */
3205
3366
  async createView(objectName, spec) {
3206
3367
  await this.connect();
@@ -3220,48 +3381,184 @@ var ObjectStackAdapter = class {
3220
3381
  data: spec?.data || { provider: "object", object: objectName }
3221
3382
  };
3222
3383
  const result = await this.client.meta.saveItem("view", name, fullSpec);
3223
- this.metadataCache.invalidate?.(`views:${objectName}`);
3384
+ this.invalidateViewKeys(objectName, name);
3224
3385
  if (result && result.item) return result.item;
3225
3386
  return fullSpec;
3226
3387
  }
3227
3388
  /**
3228
3389
  * Apply a partial update to an existing overlay view. Reads the current
3229
- * overlay (or seeds from artifact), merges, and writes back. ADR-0005
3230
- * overlay rows store the *full* view document, so partial updates require
3231
- * a read-merge-write cycle.
3390
+ * document, merges, and writes it back. ADR-0005 overlay rows store the
3391
+ * *full* view document, so partial updates require a read-merge-write cycle.
3392
+ *
3393
+ * **Both halves address the same row (#4139).** A view has two possible
3394
+ * homes and the read must resolve the one the write will target:
3395
+ *
3396
+ * - a pending per-item **draft** (`?state=draft` / `?mode=draft`) — where
3397
+ * ADR-0034 stages every runtime-created view, so a view made from the `+`
3398
+ * tab lives ONLY here until an explicit Publish;
3399
+ * - the **published** overlay (`client.meta.getItem` / `saveItem`).
3400
+ *
3401
+ * The draft is probed FIRST, and a hit is merged and written straight back
3402
+ * as a draft. Two things that ordering buys, both load-bearing:
3403
+ *
3404
+ * 1. A draft-only view is no longer invisible to the read. It used to 404,
3405
+ * and a `catch {}` labelled "treat missing as create-equivalent"
3406
+ * substituted `current = {}` — so a rename merged onto nothing and went
3407
+ * out as a `{label, name, object}` partial the server rejects (422),
3408
+ * while the draft row the UI reads back through `?preview=draft` kept the
3409
+ * old label. The edit was lost with no error surfaced to the user.
3410
+ * 2. A draft is never bypassed. Writing the published row while a draft is
3411
+ * pending would put the edit somewhere the draft shadows — and Publish
3412
+ * would then overwrite it with the pre-edit body, losing the change a
3413
+ * second time, later, where nothing connects it to this call.
3414
+ *
3415
+ * A draft edit stays a draft: `mode: 'draft'` keeps ADR-0037's guarantee
3416
+ * that nothing the preview shows goes live until Publish. Renaming a
3417
+ * *published* view (no draft pending) is unchanged — it writes the
3418
+ * published overlay, as before.
3419
+ *
3420
+ * **Both halves invalidate through {@link invalidateViewKeys}** (objectui#4363,
3421
+ * #4373) — same call, same key set, so the draft half's deliberate
3422
+ * over-invalidation is the seam's uniform rule rather than a per-branch
3423
+ * decision this method has to keep re-making.
3424
+ *
3425
+ * @throws when the view resolves in neither home, or when either read fails
3426
+ * for any other reason (network, permission). Both used to be swallowed
3427
+ * and converted into the bad partial write above; a caller that wants a
3428
+ * view created should call {@link createView}, which is the operation that
3429
+ * actually means "create".
3232
3430
  */
3233
3431
  async updateView(objectName, viewName, partial) {
3234
3432
  await this.connect();
3235
- let current = {};
3433
+ const metaClient = this.metadataClient();
3434
+ const draft = unwrapViewDraft(
3435
+ await metaClient.get("view", viewName, { state: "draft" })
3436
+ );
3437
+ if (draft) {
3438
+ const mergedDraft = mergeViewPatch(draft, partial, viewName, objectName);
3439
+ await metaClient.save("view", viewName, mergedDraft, { mode: "draft" });
3440
+ this.invalidateViewKeys(objectName, viewName);
3441
+ return mergedDraft;
3442
+ }
3443
+ let current;
3236
3444
  try {
3237
3445
  const r = await this.client.meta.getItem("view", viewName);
3238
3446
  current = r && (r.item || r) || {};
3239
3447
  if (current?.list) current = current.list;
3240
- } catch {
3448
+ } catch (err) {
3449
+ if (is404Error(err)) {
3450
+ throw Object.assign(
3451
+ new Error(
3452
+ `updateView: view "${viewName}" not found on object "${objectName}" \u2014 no pending draft and no published overlay. Use createView() to create one.`
3453
+ ),
3454
+ { cause: err }
3455
+ );
3456
+ }
3457
+ throw err;
3241
3458
  }
3242
- const merged = {
3243
- ...current,
3244
- ...partial,
3245
- name: viewName,
3246
- object: current?.object || current?.data?.object || objectName
3247
- };
3459
+ const merged = mergeViewPatch(current, partial, viewName, objectName);
3248
3460
  const result = await this.client.meta.saveItem("view", viewName, merged);
3249
- this.metadataCache.invalidate?.(`views:${objectName}`);
3250
- this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
3461
+ this.invalidateViewKeys(objectName, viewName);
3251
3462
  if (result && result.item) return result.item;
3252
3463
  return merged;
3253
3464
  }
3254
3465
  /**
3255
- * Delete an overlay view (reset to artifact default if one exists, or
3256
- * remove entirely if it was a user-created view). Routes to
3257
- * `DELETE /api/v1/meta/view/:name`.
3466
+ * Delete an overlay view — from **every home it has**.
3467
+ *
3468
+ * A view has two possible homes, the same two {@link updateView} addresses:
3469
+ * the pending per-item **draft** (`DELETE /meta/view/:name?state=draft`) and
3470
+ * the **published** overlay (`DELETE /meta/view/:name`). This method used to
3471
+ * issue only the second, and the three cases came out like this (#4479):
3472
+ *
3473
+ * | case | before |
3474
+ * |--------------------|-----------------------------------------------------|
3475
+ * | draft-only view | BUG — the published delete answered `reset:false` / |
3476
+ * | | "nothing to delete", the draft survived, and the tab |
3477
+ * | | was still there after reload |
3478
+ * | published-only | correct — `reset:true`, tab gone |
3479
+ * | published + draft | ACCIDENTALLY correct — the published row went, so |
3480
+ * | | the tab went; the orphan draft stayed behind |
3481
+ *
3482
+ * **Why this is not the mechanical mirror of #4139.** `updateView` probes
3483
+ * the draft first and writes back to whichever home the read resolved, and
3484
+ * that is right for an update in all three cases. Copying it here would be
3485
+ * wrong in the third: `persistRuntimeMetadata` (app-shell) stages EVERY
3486
+ * runtime edit as a draft, so "publish a view, then edit it" routinely
3487
+ * produces a pair — and a draft-first-only delete on a pair discards the
3488
+ * draft and leaves the published row serving the view. That is not Delete
3489
+ * view, it is **Discard draft**, a deliberately different operation that
3490
+ * already exists (`discardRuntimeDraft`, documented as "the published
3491
+ * overlay is untouched"). The clean statement of the asymmetry: for an
3492
+ * update, one home is the right home; for a delete, "remove this view" is
3493
+ * satisfied only when NO home is left serving it.
3494
+ *
3495
+ * So both homes are deleted, **draft first**. The order is load-bearing on
3496
+ * the failure path: a fault between the two calls leaves the PUBLISHED
3497
+ * overlay intact, so the view is still served and the operation is cleanly
3498
+ * retryable. The reverse order would strand a draft-only view — which is
3499
+ * precisely the bug shape above.
3500
+ *
3501
+ * **Two blind calls, no probe.** The framework's `deleteMetaItem` answers a
3502
+ * missing home with a **200** carrying `reset:false` (`"No pending draft
3503
+ * for view/x."` / `"No view 'x' found — nothing to delete."`), never a 404,
3504
+ * so there is nothing for a probe to protect against. `updateView` needs its
3505
+ * probe for a different reason — its read must resolve the row the merge
3506
+ * writes back to — and that reason has no counterpart for a delete.
3507
+ *
3508
+ * **One transport, one error contract.** Both halves go through
3509
+ * {@link MetadataClient} (`reset`), the transport that can express the
3510
+ * `?state=` qualifier and the one `updateView`'s draft half already uses.
3511
+ * The published half used to go through `client.meta.deleteItem`; measured,
3512
+ * that issues the byte-identical request (`DELETE
3513
+ * {baseUrl}/api/v1/meta/view/:name`, no environment scoping is configured on
3514
+ * this adapter), so routing it here costs no addressing change and buys a
3515
+ * single `MetadataError` shape across both calls instead of two.
3516
+ *
3517
+ * @returns `deleted` is true only when no home is left serving the view AND
3518
+ * at least one actually held a row. A view that existed in neither home
3519
+ * still answers `false`, unchanged. The per-home outcomes are additive:
3520
+ * a partial result is observable rather than rounded up to `true`.
3521
+ * @throws when either delete fails, matching {@link updateView}'s
3522
+ * convention of surfacing the fault rather than degrading. A failure of
3523
+ * the PUBLISHED half after the draft was discarded carries the partial
3524
+ * state on the error's `outcome` — "draft gone, overlay left" is exactly
3525
+ * what the old `{ deleted: boolean }` could not express.
3526
+ *
3527
+ * Invalidates through {@link invalidateViewKeys}: the deleted row leaves the
3528
+ * batch override map too, and a ghost entry there is what the object page
3529
+ * would keep applying (objectui#4363). Fired in a `finally`, so it happens
3530
+ * once per call on EVERY outcome including the throw — after a half-failure
3531
+ * the draft row really is gone, and #4363's asymmetry decides it: an
3532
+ * unnecessary invalidation costs one refetch, a missed one costs the cache's
3533
+ * full 5-minute TTL of stale overrides.
3258
3534
  */
3259
3535
  async deleteView(objectName, viewName) {
3260
3536
  await this.connect();
3261
- const result = await this.client.meta.deleteItem("view", viewName);
3262
- this.metadataCache.invalidate?.(`views:${objectName}`);
3263
- this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
3264
- return { deleted: !!(result?.deleted ?? result?.reset ?? true) };
3537
+ const metaClient = this.metadataClient();
3538
+ try {
3539
+ const draft = readViewDeleteReceipt(
3540
+ await metaClient.reset("view", viewName, { state: "draft" })
3541
+ );
3542
+ let published;
3543
+ try {
3544
+ published = readViewDeleteReceipt(await metaClient.reset("view", viewName));
3545
+ } catch (err) {
3546
+ const outcome = {
3547
+ deleted: false,
3548
+ draft,
3549
+ published: { removed: false }
3550
+ };
3551
+ throw Object.assign(
3552
+ new Error(
3553
+ `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.`
3554
+ ),
3555
+ { cause: err, outcome }
3556
+ );
3557
+ }
3558
+ return { deleted: draft.removed || published.removed, draft, published };
3559
+ } finally {
3560
+ this.invalidateViewKeys(objectName, viewName);
3561
+ }
3265
3562
  }
3266
3563
  /**
3267
3564
  * Get an application definition by name or ID.
@@ -3277,7 +3574,7 @@ var ObjectStackAdapter = class {
3277
3574
  try {
3278
3575
  const cacheKey = `app:${appId}`;
3279
3576
  return await this.metadataCache.get(cacheKey, async () => {
3280
- const result = await this.client.meta.getItem("apps", appId);
3577
+ const result = await this.client.meta.getItem("app", appId);
3281
3578
  if (result && result.item) return result.item;
3282
3579
  return result ?? null;
3283
3580
  });
@@ -3285,6 +3582,56 @@ var ObjectStackAdapter = class {
3285
3582
  return null;
3286
3583
  }
3287
3584
  }
3585
+ /**
3586
+ * Ask the by-name meta app route WHY an app is not in this session's app list
3587
+ * (objectui#4252).
3588
+ *
3589
+ * The app LIST is the generic metadata list route `GET /api/v1/meta/:type`,
3590
+ * requested with the singular type segment `app` — the same address this
3591
+ * method appends a name to below, and the one `MetadataProvider` reads its
3592
+ * items from. The server filters that list per session in `filterAppForUser`
3593
+ * (`packages/rest/src/rest-server.ts`, applied inside the `:type` list handler
3594
+ * once the type segment resolves to `app`), so an app withheld by
3595
+ * `requiredPermissions` and an app
3596
+ * that does not exist are byte-identical there: both are simply absent. A
3597
+ * console reading only that list has one fact and two conditions, and it
3598
+ * renders its copy for the wrong one — "it may still be publishing" over a
3599
+ * permanent authorization decision, which cost a downstream acceptance round
3600
+ * two test batches spent chasing a platform defect that was a missing
3601
+ * permission-set binding.
3602
+ *
3603
+ * The maintainer ruling (2026-08-12) put the answer on the BY-NAME route
3604
+ * rather than in the list, so the enumeration surface is not widened past what
3605
+ * a by-name probe already implies (objectstack#8013 / PR #8135): an app that
3606
+ * exists and whose `requiredPermissions` the session lacks answers `403` with
3607
+ * `PERMISSION_DENIED` in the declared envelope, and absence — a nonexistent
3608
+ * name, an unpublished app, an app gated by an absent optional service —
3609
+ * keeps answering `404 RESOURCE_NOT_FOUND`.
3610
+ *
3611
+ * ## Why this is a separate method and not a flavour of {@link getApp}
3612
+ *
3613
+ * - `getApp` degrades EVERY failure to `null`, which is exactly the
3614
+ * conflation this exists to undo; changing it would silently re-point its
3615
+ * own callers' fallback-to-static-config path.
3616
+ * - `getApp` memoises in `metadataCache`. A verdict about the CALLER must not
3617
+ * be cached beside a document about the APP — one grant, and a cached
3618
+ * denial outlives the session it described.
3619
+ *
3620
+ * Nothing here throws: a probe that cannot reach an answer returns `unknown`
3621
+ * and the caller keeps whatever it was already showing. Only the measured
3622
+ * `code` produces `denied` — never a status, never a message (objectui#4408).
3623
+ *
3624
+ * @param appName - the app name as it appears in the URL segment
3625
+ */
3626
+ async probeAppAccess(appName) {
3627
+ if (!appName) return "unknown";
3628
+ try {
3629
+ await this.client.meta.getItem("app", appName);
3630
+ return "granted";
3631
+ } catch (err) {
3632
+ return isAppPermissionDeniedError(err) ? "denied" : "unknown";
3633
+ }
3634
+ }
3288
3635
  /**
3289
3636
  * Get a page definition from ObjectStack.
3290
3637
  * Uses the metadata API to fetch page layouts.
@@ -3295,7 +3642,7 @@ var ObjectStackAdapter = class {
3295
3642
  try {
3296
3643
  const cacheKey = `page:${pageId}`;
3297
3644
  return await this.metadataCache.get(cacheKey, async () => {
3298
- const result = await this.client.meta.getItem("pages", pageId);
3645
+ const result = await this.client.meta.getItem("page", pageId);
3299
3646
  if (result && result.item) return result.item;
3300
3647
  return result ?? null;
3301
3648
  });
@@ -3385,6 +3732,15 @@ var ObjectStackAdapter = class {
3385
3732
  if (failure.kind === "rejected") {
3386
3733
  throw new AnalyticsQueryRejectedError(failure.message, failure.code);
3387
3734
  }
3735
+ if (failure.kind === "unauthenticated") {
3736
+ throw new AnalyticsUnauthenticatedError({
3737
+ serverCode: failure.code,
3738
+ serverMessage: failure.message
3739
+ });
3740
+ }
3741
+ if (failure.kind === "cube-not-found") {
3742
+ throw e;
3743
+ }
3388
3744
  if (failure.kind === "not-installed") {
3389
3745
  this.warnAnalyticsCapabilityOnce(failure.message);
3390
3746
  }
@@ -3437,7 +3793,17 @@ var ObjectStackAdapter = class {
3437
3793
  * silently returning wrong numbers.
3438
3794
  *
3439
3795
  * @param dataset - An inline dataset definition (draft) OR a saved dataset name.
3440
- * @param selection - Dimension/measure names to project + runtime directives.
3796
+ * @param selection - The spec's {@link DatasetSelection} — dimension/measure
3797
+ * names to project plus runtime directives. This parameter IS the spec type
3798
+ * by reference, never a local restatement of it (objectui#3613): a hand
3799
+ * copy of a contract is a second dialect of it, and the copy this replaced
3800
+ * had already drifted three ways from `@objectstack/spec` — it required
3801
+ * `compareTo.dimension` (optional since objectstack#5011, and resolved by
3802
+ * the EXECUTOR, so requiring it pushed callers into exactly the
3803
+ * consumer-side dimension guess AGENTS.md #0.1 forbids), it widened
3804
+ * `timeDimensions` to `unknown[]` and `runtimeFilter` to
3805
+ * `Record<string, unknown>`, and it had never grown `dateGranularity` at
3806
+ * all. Pinned in `queryDataset.test.ts`.
3441
3807
  */
3442
3808
  async queryDataset(dataset, selection) {
3443
3809
  await this.connect();
@@ -3454,14 +3820,35 @@ var ObjectStackAdapter = class {
3454
3820
  body: JSON.stringify(requestBody)
3455
3821
  });
3456
3822
  if (!res.ok) {
3457
- let detail = "";
3823
+ let errBody;
3458
3824
  try {
3459
- const errBody = await res.json();
3460
- detail = errBody?.message || errBody?.error || JSON.stringify(errBody);
3825
+ errBody = await res.json();
3461
3826
  } catch {
3462
3827
  }
3463
- if (res.status === 501 || res.status === 404) {
3464
- throw new AnalyticsNotInstalledError("POST /analytics/dataset/query", detail || void 0);
3828
+ const { code: serverCode, message: serverMessage } = readAnalyticsErrorEnvelope(errBody);
3829
+ const detail = serverMessage || (errBody === void 0 ? "" : JSON.stringify(errBody));
3830
+ const surface = "POST /analytics/dataset/query";
3831
+ const requestedDatasetName = typeof dataset === "string" ? dataset : typeof dataset?.name === "string" ? String(dataset.name) : void 0;
3832
+ if (errorCodeIsAnyOf({ code: serverCode }, ["NOT_IMPLEMENTED", "ROUTE_NOT_FOUND"])) {
3833
+ throw new AnalyticsNotInstalledError(surface, serverMessage, serverCode);
3834
+ }
3835
+ if (errorCodeIs({ code: serverCode }, "NOT_FOUND")) {
3836
+ throw new AnalyticsDatasetNotFoundError({
3837
+ datasetName: requestedDatasetName,
3838
+ serverCode,
3839
+ serverMessage
3840
+ });
3841
+ }
3842
+ if (errorCodeIs({ code: serverCode }, "UNAUTHENTICATED")) {
3843
+ throw new AnalyticsUnauthenticatedError({ serverCode, serverMessage });
3844
+ }
3845
+ if (serverCode === void 0) {
3846
+ if (ANALYTICS_ABSENT_STATUSES.includes(res.status)) {
3847
+ throw new AnalyticsNotInstalledError(surface, detail || void 0);
3848
+ }
3849
+ if (res.status === 401) {
3850
+ throw new AnalyticsUnauthenticatedError({ serverMessage });
3851
+ }
3465
3852
  }
3466
3853
  throw new Error(`Dataset query failed: ${res.status} ${res.statusText}${detail ? ` \u2014 ${detail}` : ""}`);
3467
3854
  }
@@ -3472,8 +3859,9 @@ var ObjectStackAdapter = class {
3472
3859
  const object = typeof data?.object === "string" ? data.object : void 0;
3473
3860
  const dimensionFields = data?.dimensionFields && typeof data.dimensionFields === "object" ? data.dimensionFields : void 0;
3474
3861
  const drillRawRows = Array.isArray(data?.drillRawRows) ? data.drillRawRows : void 0;
3862
+ const drillRanges = Array.isArray(data?.drillRanges) ? data.drillRanges : void 0;
3475
3863
  const totals = Array.isArray(data?.totals) ? data.totals : void 0;
3476
- return { rows, fields, object, dimensionFields, drillRawRows, totals };
3864
+ return { rows, fields, object, dimensionFields, drillRawRows, drillRanges, totals };
3477
3865
  }
3478
3866
  /** Client-side aggregation fallback */
3479
3867
  /**
@@ -3717,40 +4105,44 @@ function createObjectStackAdapter(config) {
3717
4105
  return new ObjectStackAdapter(config);
3718
4106
  }
3719
4107
  export {
4108
+ API_ACCESS_DENIED_CODES,
4109
+ APP_PERMISSION_DENIED_CODE,
4110
+ AnalyticsDatasetNotFoundError,
3720
4111
  AnalyticsNotInstalledError,
3721
4112
  AnalyticsQueryRejectedError,
4113
+ AnalyticsUnauthenticatedError,
3722
4114
  AuthenticationError,
3723
4115
  BulkOperationError,
3724
- CloudOperations,
3725
4116
  ConcurrentUpdateError,
3726
4117
  ConnectionError,
3727
4118
  DataApiValidationError,
3728
4119
  FILTER_OPERATOR_ALIASES,
3729
- IntegrationManager,
3730
4120
  MalformedFilterError,
3731
4121
  MetadataClient,
3732
4122
  MetadataNotFoundError,
3733
4123
  ObjectStackAdapter,
3734
4124
  ObjectStackError,
3735
- SecurityManager,
3736
- calculateAutoLayout,
4125
+ VIEW_OVERLAY_OWNED_KEYS,
3737
4126
  classifyAnalyticsFailure,
3738
4127
  clearSharedDiscoveryCache,
3739
- createDefaultCanvasConfig,
3740
4128
  createErrorFromResponse,
3741
4129
  createObjectStackAdapter,
3742
4130
  createObjectStackUserStateAdapter,
3743
- generateContractManifest,
4131
+ createQuietHttpLogger,
4132
+ formatHttpFailureMessage,
3744
4133
  getSharedDiscovery,
3745
4134
  is404Error,
3746
4135
  isAnalyticsNotInstalledError,
4136
+ isApiAccessDeniedError,
4137
+ isAppPermissionDeniedError,
3747
4138
  isConcurrentUpdateError,
3748
4139
  isErrorType,
3749
4140
  isMalformedFilterError,
3750
4141
  isObjectStackError,
4142
+ narrowPersonalizationOverlay,
3751
4143
  normaliseClientError,
4144
+ readSaveAdvisories,
3752
4145
  readTransactionalBatchCapability,
3753
4146
  serializeOrderBy,
3754
- snapToGrid,
3755
- validatePluginContract
4147
+ viewItemObjectName
3756
4148
  };