@object-ui/data-objectstack 17.6.0 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4,6 +4,72 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4
4
 
5
5
  // src/index.ts
6
6
  import { ObjectStackClient } from "@objectstack/client";
7
+ import { VIEW_CONSOLE_ROUND_TRIP_KEYS } from "@objectstack/spec/ui";
8
+ import { DroppedFieldsEventSchema } from "@objectstack/spec/data";
9
+ import { isFilterAST, parseFilterAST } from "@objectstack/spec/data";
10
+ import { isRefusedTextComparand as isRefusedTextComparand2, textComparandRefusalReason as textComparandRefusalReason2 } from "@objectstack/spec/data";
11
+ import { ValidateDataIssueSchema } from "@objectstack/spec/api";
12
+ import { stripReadDecorations as stripReadDecorations2 } from "@objectstack/spec/kernel";
13
+
14
+ // src/metadata-client.ts
15
+ import { FlowRegistrationSchema, GetMetaItemLayeredResponseSchema } from "@objectstack/spec/api";
16
+
17
+ // src/object-metadata-write-guard.ts
18
+ var RELATIONSHIP_TYPES_REQUIRING_REFERENCE = ["lookup", "master_detail"];
19
+ var CHOICE_TYPES_REQUIRING_OPTIONS = ["select", "radio"];
20
+ var OBJECT_METADATA_TYPE = "object";
21
+ function isUsableTarget(reference) {
22
+ return typeof reference === "string" && reference.trim() !== "";
23
+ }
24
+ function describeTarget(reference) {
25
+ if (reference === void 0) return "no `reference` key at all";
26
+ if (reference === null) return "a `null` `reference`";
27
+ if (typeof reference !== "string") return `a \`reference\` of type \`${typeof reference}\``;
28
+ if (reference === "") return "an empty `reference`";
29
+ return "a whitespace-only `reference`";
30
+ }
31
+ function hasOptionSource(def) {
32
+ return Array.isArray(def.options) && def.options.length > 0 || typeof def.picklist === "string";
33
+ }
34
+ function describeOptions(options) {
35
+ if (options === void 0) return "no `options` key and no `picklist`";
36
+ if (Array.isArray(options)) return "an empty `options` list and no `picklist`";
37
+ if (options === null) return "a `null` `options` and no `picklist`";
38
+ return `an \`options\` value of type \`${typeof options}\` and no \`picklist\``;
39
+ }
40
+ function fieldEntries(fields) {
41
+ if (Array.isArray(fields)) {
42
+ return fields.flatMap((raw, index) => {
43
+ if (!raw || typeof raw !== "object") return [];
44
+ const record = raw;
45
+ const name = typeof record.name === "string" && record.name ? record.name : `[${index}]`;
46
+ return [{ name, def: record }];
47
+ });
48
+ }
49
+ if (fields && typeof fields === "object") {
50
+ return Object.entries(fields).flatMap(
51
+ ([name, def]) => def && typeof def === "object" ? [{ name, def }] : []
52
+ );
53
+ }
54
+ return [];
55
+ }
56
+ function assertObjectMetadataWritable(type, item, _writer) {
57
+ if (String(type) !== OBJECT_METADATA_TYPE) return;
58
+ if (!item || typeof item !== "object") return;
59
+ const fields = item.fields;
60
+ for (const { name, def } of fieldEntries(fields)) {
61
+ if (CHOICE_TYPES_REQUIRING_OPTIONS.includes(String(def.type)) && !hasOptionSource(def)) {
62
+ throw new Error(
63
+ `The object was not saved: the field \`${name}\` is a \`${String(def.type)}\` with no options: it carries ${describeOptions(def.options)}, so it offers nothing to choose and nothing to check a stored value against. A choice field needs at least one option or a shared \`picklist\`, so this draft stays here until it has one. Add an option, or change the field to a non-choice type.`
64
+ );
65
+ }
66
+ if (!RELATIONSHIP_TYPES_REQUIRING_REFERENCE.includes(String(def.type))) continue;
67
+ if (isUsableTarget(def.reference)) continue;
68
+ throw new Error(
69
+ `The object was not saved: the field \`${name}\` is a \`${String(def.type)}\` and carries ${describeTarget(def.reference)}, so it names no object to link to. The server refuses the same document with a 422 on \`fields.${name}.reference\`, and that refusal blocks every later save of this object for as long as the half-filled field rides along in the draft. Pick the target object, or change the field to a non-relationship type.`
70
+ );
71
+ }
72
+ }
7
73
 
8
74
  // src/metadata-client.ts
9
75
  function readSaveAdvisories(body) {
@@ -16,6 +82,34 @@ function readSaveAdvisories(body) {
16
82
  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
83
  });
18
84
  }
85
+ function readRegistrations(raw) {
86
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
87
+ const out = {};
88
+ for (const [name, verdict] of Object.entries(raw)) {
89
+ const parsed = FlowRegistrationSchema.safeParse(verdict);
90
+ if (parsed.success) out[name] = parsed.data;
91
+ }
92
+ return out;
93
+ }
94
+ var PROTECTION_ENVELOPE_KEYS = [
95
+ "overlayScope",
96
+ "lock",
97
+ "lockReason",
98
+ "lockSource",
99
+ "lockDocsUrl",
100
+ "provenance",
101
+ "packageId",
102
+ "packageVersion",
103
+ "editable",
104
+ "deletable",
105
+ "resettable"
106
+ ];
107
+ function unrecognizedEnvelopeKeys(body) {
108
+ const shape = GetMetaItemLayeredResponseSchema.shape;
109
+ return PROTECTION_ENVELOPE_KEYS.filter(
110
+ (key) => body[key] !== void 0 && !shape[key].safeParse(body[key]).success
111
+ );
112
+ }
19
113
  var META_PREFIX = "/meta";
20
114
  var API_PREFIX = "/api/v1";
21
115
  function buildBase(config) {
@@ -28,6 +122,16 @@ function buildBase(config) {
28
122
  }
29
123
  return `${trimmed}${scoped}`;
30
124
  }
125
+ function packagesBaseOf(metaBase) {
126
+ const origin = metaBase.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, "");
127
+ return `${origin}${API_PREFIX}/packages`;
128
+ }
129
+ function unwrapDispatcherEnvelope(body) {
130
+ if (!body || typeof body !== "object") return {};
131
+ const root = body;
132
+ const data = root.data;
133
+ return data && typeof data === "object" && !Array.isArray(data) ? data : root;
134
+ }
31
135
  async function parseError(res) {
32
136
  let body;
33
137
  try {
@@ -53,6 +157,26 @@ function isMetaItemEnvelope(value) {
53
157
  const env = value;
54
158
  return typeof env.type === "string" && typeof env.name === "string" && "item" in env;
55
159
  }
160
+ var pendingReads = /* @__PURE__ */ new WeakMap();
161
+ function pendingReadsOn(transport) {
162
+ let reads = pendingReads.get(transport);
163
+ if (!reads) {
164
+ reads = /* @__PURE__ */ new Map();
165
+ pendingReads.set(transport, reads);
166
+ }
167
+ return reads;
168
+ }
169
+ function handOut(entry) {
170
+ entry.waiting += 1;
171
+ return entry.read.then((value) => {
172
+ entry.waiting -= 1;
173
+ return entry.waiting === 0 ? value : ownCopy(value);
174
+ });
175
+ }
176
+ function ownCopy(value) {
177
+ if (value === null || typeof value !== "object") return value;
178
+ return typeof structuredClone === "function" ? structuredClone(value) : JSON.parse(JSON.stringify(value));
179
+ }
56
180
  var MetadataClient = class _MetadataClient {
57
181
  constructor(config) {
58
182
  __publicField(this, "base");
@@ -60,7 +184,7 @@ var MetadataClient = class _MetadataClient {
60
184
  __publicField(this, "headers");
61
185
  /** ADR-0037: when true, reads render the draft-overlaid world. */
62
186
  __publicField(this, "previewDrafts");
63
- /** #4133 — sink for post-save advisory findings; see the config field. */
187
+ /** #4133 / #5026 — sink for every write door's advisory findings; see the config field. */
64
188
  __publicField(this, "onSaveAdvisory");
65
189
  this.base = buildBase(config);
66
190
  this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
@@ -103,34 +227,102 @@ var MetadataClient = class _MetadataClient {
103
227
  ...this.onSaveAdvisory ? { onSaveAdvisory: this.onSaveAdvisory } : {}
104
228
  });
105
229
  }
230
+ /**
231
+ * Share one in-flight GET among the callers asking the same method for the
232
+ * same URL with the same headers through the same transport (objectui#11797).
233
+ *
234
+ * Opening a Studio package mounts several readers that ask the same
235
+ * question at once, and each one sent its own request. The first call for a
236
+ * key sends `read()`; a call that arrives while it is pending waits for that
237
+ * request instead of sending another.
238
+ *
239
+ * ⛔ Not a response cache, deliberately — the rules `findOne` keeps on the
240
+ * data adapter (objectui#11699):
241
+ * - the entry goes the moment the read settles, so a call made after an
242
+ * answer always reaches the network (there is no reuse window: a client
243
+ * cannot see every write that changes metadata, an agent's server-side
244
+ * edits among them, and a window would answer the read after such a write
245
+ * with the state from before it);
246
+ * - a rejection reaches every caller already waiting and is not remembered;
247
+ * - a write sent through the same transport drops every pending read when
248
+ * it lands (see {@link sendWrite}), so a read asked after a save or a
249
+ * publish is never answered by a request sent before it;
250
+ * - every caller, the first one included, gets an answer no other caller
251
+ * holds (see {@link handOut}).
252
+ */
253
+ shareRead(reader, url, read) {
254
+ const reads = pendingReadsOn(this.fetchImpl);
255
+ const key = JSON.stringify([reader, url, Object.entries(this.headers).sort(([a], [b]) => a.localeCompare(b))]);
256
+ let entry = reads.get(key);
257
+ if (!entry) {
258
+ const created = { read: read(), waiting: 0 };
259
+ reads.set(key, created);
260
+ const settle = () => {
261
+ if (reads.get(key) === created) reads.delete(key);
262
+ };
263
+ created.read.then(settle, settle);
264
+ entry = created;
265
+ }
266
+ return handOut(entry);
267
+ }
268
+ /**
269
+ * Send a write, then drop every read still pending on this transport
270
+ * (objectui#11797): a read on the wire when the write lands was asked before
271
+ * it, so a caller asking after the write must not join it. Dropped however
272
+ * the write ends — a refused or interrupted write may still have changed
273
+ * something. The callers already holding a pending read keep its answer.
274
+ */
275
+ async sendWrite(url, init) {
276
+ try {
277
+ return await this.fetchImpl(url, init);
278
+ } finally {
279
+ pendingReads.get(this.fetchImpl)?.clear();
280
+ }
281
+ }
106
282
  /** List all registered metadata types (returns the registry rows). */
107
283
  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 }));
116
- }
117
- return [];
284
+ return this.shareRead("listTypes", this.base, async () => {
285
+ const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
286
+ if (!res.ok) throw await parseError(res);
287
+ const data = await res.json();
288
+ if (Array.isArray(data)) return data;
289
+ if (data && Array.isArray(data.entries)) return data.entries;
290
+ if (data && Array.isArray(data.items)) return data.items;
291
+ if (data && Array.isArray(data.types)) {
292
+ return data.types.map((t) => ({ type: t }));
293
+ }
294
+ return [];
295
+ });
118
296
  }
119
297
  /** List items of a metadata type (e.g. `object`, `field`, `view`). */
120
298
  async list(type, options = {}) {
299
+ return (await this.listResponse(type, options)).items;
300
+ }
301
+ /**
302
+ * The same list read as {@link list}, with what the server serves beside
303
+ * the items (objectui#12349): today that is the automation engine's
304
+ * `registrations` on a `flow` list — see {@link MetadataListAnswer}.
305
+ *
306
+ * One request for both methods: `list` is this read with the items picked
307
+ * out, so a caller asking both at once shares one in-flight GET.
308
+ */
309
+ async listResponse(type, options = {}) {
121
310
  const params = [];
122
311
  if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
123
312
  if (this.previewDrafts) params.push("preview=draft");
124
313
  const qs = params.length ? `?${params.join("&")}` : "";
125
314
  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;
132
- }
133
- return [];
315
+ return this.shareRead("list", url, async () => {
316
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
317
+ if (!res.ok) throw await parseError(res);
318
+ const data = await res.json();
319
+ if (Array.isArray(data)) return { items: data };
320
+ if (data && Array.isArray(data.items)) {
321
+ const registrations = readRegistrations(data.registrations);
322
+ return { items: data.items, ...registrations ? { registrations } : {} };
323
+ }
324
+ return { items: [] };
325
+ });
134
326
  }
135
327
  /**
136
328
  * List pending DRAFT items (ADR-0033) — what an AI authored but nobody
@@ -146,11 +338,13 @@ var MetadataClient = class _MetadataClient {
146
338
  if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
147
339
  const qs = params.length ? `?${params.join("&")}` : "";
148
340
  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 ?? [];
341
+ return this.shareRead("listDrafts", url, async () => {
342
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
343
+ if (!res.ok) throw await parseError(res);
344
+ const data = await res.json();
345
+ if (Array.isArray(data)) return data;
346
+ return data?.drafts ?? data?.data?.drafts ?? [];
347
+ });
154
348
  }
155
349
  /**
156
350
  * Fetch a single metadata item and return the response body EXACTLY as the
@@ -169,10 +363,12 @@ var MetadataClient = class _MetadataClient {
169
363
  if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
170
364
  const qs = params.length ? `?${params.join("&")}` : "";
171
365
  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();
366
+ return this.shareRead("readItemResponse", url, async () => {
367
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
368
+ if (res.status === 404) return null;
369
+ if (!res.ok) throw await parseError(res);
370
+ return await res.json();
371
+ });
176
372
  }
177
373
  /**
178
374
  * Get a single metadata item. Returns the unwrapped item CONTENT — the
@@ -214,15 +410,90 @@ var MetadataClient = class _MetadataClient {
214
410
  * {@link get} is deliberate and long-standing (the draft envelope's identity
215
411
  * and protection carriers are part of what a draft reader inspects), so it
216
412
  * 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.
413
+ * `get()`'s unwrap.
414
+ *
415
+ * ⛔ Do NOT hand `.item` to a gate or a write yourself: the body this serves
416
+ * is DECORATED (`_draft`, then `_diagnostics` from `decorateMetadataItem`),
417
+ * and the spec says a served body is not a valid input to the schema that
418
+ * produced it until those come off. {@link extractDraftBody} is the one
419
+ * reader for this method's answer — it takes the presence verdict and then
420
+ * the strip, in that order. That function used to be copy-pasted into four
421
+ * views and only one copy knew the rule (objectui#8181).
422
+ *
423
+ * Its tolerant siblings exist for the OTHER client method: `unwrapDraftBody`
424
+ * (app-shell) and `unwrapViewDraft` (this package) read a draft that arrived
425
+ * through {@link get}, which already unwrapped the envelope. They split on
426
+ * which method feeds them, not on what they mean — all three strip.
427
+ *
428
+ * objectui#11799 — whether a draft exists is answered by the pending-drafts
429
+ * ledger first (see {@link draftLedgerMayHold}), and `?state=draft` is sent
430
+ * only when the ledger lists the name. The item read answers 404 when there
431
+ * is no draft, so opening every item in Studio used to log one error per
432
+ * item for an answer the ledger gives with a 200.
219
433
  */
220
434
  async getDraft(type, name, options = {}) {
435
+ if (!await this.draftLedgerMayHold(name)) return null;
221
436
  return this.readItemResponse(type, name, {
222
437
  state: "draft",
223
438
  ...options.packageId ? { packageId: options.packageId } : {}
224
439
  });
225
440
  }
441
+ /**
442
+ * Whether the pending-drafts ledger (`GET /meta/_drafts`) may hold a draft
443
+ * named `name` (objectui#11799).
444
+ *
445
+ * Read through {@link listDrafts}, the shared read objectui#11797 made: a
446
+ * question asked while a ledger read is pending waits for it, and a write
447
+ * through this transport drops it when the write lands ({@link sendWrite}),
448
+ * so a draft saved a moment ago is never reported absent by a read sent
449
+ * before the save.
450
+ *
451
+ * The WHOLE ledger, matched by name. The `?state=draft` read finds a row
452
+ * under either spelling of the type, and, when it names a package, that
453
+ * package's row or the package-less one. The ledger's `type` and `packageId`
454
+ * filters are exact, so a filtered ledger could miss a draft the item read
455
+ * would serve, and the editor would open the published body over a pending
456
+ * draft. Unfiltered, every miss here is a real absence; a same-name row of
457
+ * another type or package only costs the item read that was always sent.
458
+ *
459
+ * A ledger that cannot be read (403 without an authoring capability, 501 on
460
+ * a kernel without drafts, a network fault) is UNKNOWN, not empty: the item
461
+ * read is sent, as before.
462
+ */
463
+ async draftLedgerMayHold(name) {
464
+ let drafts;
465
+ try {
466
+ drafts = await this.listDrafts();
467
+ } catch {
468
+ return true;
469
+ }
470
+ return drafts.some((d) => d?.name === name);
471
+ }
472
+ /**
473
+ * Report the runtime authoring gate's advisory findings for a write the
474
+ * server has ALREADY committed (#4133 for the save door, #5026 for the
475
+ * publish door).
476
+ *
477
+ * Everything here is best-effort by construction, and that is the whole
478
+ * contract: the row is committed server-side before this runs, so neither a
479
+ * malformed body, nor a missing key, nor a throwing sink may change what the
480
+ * calling method returns or whether it throws. The server emits `advisories`
481
+ * ONLY when non-empty, so a clean write costs one absent-key check.
482
+ *
483
+ * One helper rather than a copy per door: every door's response declares the
484
+ * key identically, so a second inline copy could only ever drift. The batch
485
+ * door (`ce986aafc`) calls it once per `published[]` element, handing it
486
+ * the element — the object that carries the key there — and the same
487
+ * best-effort contract covers that loop unchanged.
488
+ */
489
+ emitAdvisories(body, event) {
490
+ if (!this.onSaveAdvisory) return;
491
+ try {
492
+ const advisories = readSaveAdvisories(body);
493
+ if (advisories.length > 0) this.onSaveAdvisory({ ...event, advisories });
494
+ } catch {
495
+ }
496
+ }
226
497
  /**
227
498
  * Save (PUT) a metadata item. The framework accepts both the bare
228
499
  * item payload and the `{ item: ... }` / `{ metadata: ... }`
@@ -235,10 +506,17 @@ var MetadataClient = class _MetadataClient {
235
506
  `MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
236
507
  );
237
508
  }
509
+ if (options.ifMatch && options.ifNoneMatch) {
510
+ throw new Error(
511
+ `MetadataClient.save: ifMatch and ifNoneMatch are mutually exclusive (type="${type}", name="${name}"). Send ifMatch with the version you read, or ifNoneMatch: "*" when no row is there.`
512
+ );
513
+ }
514
+ assertObjectMetadataWritable(type, item, "MetadataClient.save");
238
515
  const params = [];
239
516
  if (options.force) params.push("force=true");
240
517
  if (options.mode === "draft") params.push("mode=draft");
241
518
  if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
519
+ if (options.surface) params.push(`surface=${encodeURIComponent(options.surface)}`);
242
520
  const qs = params.length ? `?${params.join("&")}` : "";
243
521
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
244
522
  const headers = {
@@ -246,27 +524,20 @@ var MetadataClient = class _MetadataClient {
246
524
  "Content-Type": "application/json"
247
525
  };
248
526
  if (options.ifMatch) headers["If-Match"] = options.ifMatch;
249
- const res = await this.fetchImpl(url, {
527
+ if (options.ifNoneMatch) headers["If-None-Match"] = options.ifNoneMatch;
528
+ const res = await this.sendWrite(url, {
250
529
  method: "PUT",
251
530
  headers,
252
531
  body: JSON.stringify(item)
253
532
  });
254
533
  if (!res.ok) throw await parseError(res);
255
534
  const body = await res.json();
256
- if (this.onSaveAdvisory) {
257
- try {
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 {
268
- }
269
- }
535
+ this.emitAdvisories(body, {
536
+ type,
537
+ name,
538
+ door: "save",
539
+ mode: options.mode === "draft" ? "draft" : "publish"
540
+ });
270
541
  return body;
271
542
  }
272
543
  /**
@@ -280,18 +551,178 @@ var MetadataClient = class _MetadataClient {
280
551
  * the rows and reports under `seedApplied` — a data problem never fails the
281
552
  * publish, so callers should check `seedApplied?.success` and warn the user
282
553
  * rather than assume the data went live.
554
+ *
555
+ * Same door as {@link publish} (`POST /meta/:type/:name/publish`), so it
556
+ * reports the gate's advisories the same way (#5026).
557
+ *
558
+ * The BATCH door (`POST /packages/:id/publish-drafts`) is a different route
559
+ * with a method of its own — {@link publishPackageDrafts} — and it reports
560
+ * too. It did not when this paragraph was first written: it discarded
561
+ * per-draft advisories server-side, which was objectstack#9343, and that card
562
+ * has since landed with a ruling that each `published[]` element carries
563
+ * them (`ce986aafc` is the client half).
564
+ *
565
+ * What survives that change is the READING RULE, which is about this method
566
+ * rather than about the other route: it reads the top level of the
567
+ * single-item body and nothing else. A batch-shaped body arriving HERE still
568
+ * reports nothing, because `PublishMetaItemResponse` declares no
569
+ * `published[]` and a client that went looking for findings wherever they
570
+ * might be would be inventing a contract instead of reading one.
571
+ *
572
+ * ## Why there is no `{ success, data }` unwrapping here (`7b433197d`)
573
+ *
574
+ * This method used to unwrap a dispatcher-shaped envelope before returning,
575
+ * while {@link publish} — the same route, a couple of hundred lines down —
576
+ * did not. Two spellings of one wire contract, and nothing said which was
577
+ * right. The tolerance is gone because the route was MEASURED, at the
578
+ * producer rather than inferred from the response schema:
579
+ *
580
+ * - The only mount of `POST /api/v1/meta/:type/:name/publish` is the REST
581
+ * server's own route-manager registration, which answers
582
+ * `res.json(await protocol.publishMetaItem(...))` — the protocol's object
583
+ * verbatim, with no envelope branch. The project-scoped mirror
584
+ * (`/api/v1/environments/:environmentId/...`) re-mounts that same handler.
585
+ * - The `{ success, data }` envelope has exactly one producer, the runtime
586
+ * `HttpDispatcher`, and it does not serve this route: its `/meta` domain
587
+ * has no publish branch, the dispatcher's own route ledger has no row for
588
+ * it, and a three-segment `/meta` path that is not `/published` terminates
589
+ * in a located `routeNotFound`. So no composition — REST server, the
590
+ * dispatcher-only Hono adapter, or both — puts an envelope on this body.
591
+ * - `packages/spec` states the same split in the two docstrings side by
592
+ * side: `PublishMetaItemResponseSchema` describes "the FULL body" this
593
+ * route returns and records that the REST route hands the producer's
594
+ * object to `res.json()` verbatim, while its batch sibling
595
+ * `PublishPackageDraftsResponseSchema` describes a body answered
596
+ * explicitly "inside the dispatcher's `{ success, data }` envelope".
597
+ *
598
+ * ⛔ So an enveloped body on THIS route is not a shape the platform emits,
599
+ * and unwrapping one is not a kindness: it is Commandment #0.1's lenient
600
+ * fallback, a second de-facto contract for a route that declares one. It
601
+ * also fails in the direction that hides the problem — a body that arrived
602
+ * enveloped is one this door did not actually serve, and unwrapping it
603
+ * would present that as a successful promotion.
604
+ *
605
+ * ⚠️ Sibling tolerances in this file are NOT this defect and must stay:
606
+ * {@link listDrafts} reads `data?.data?.drafts` because `GET /meta/_drafts`
607
+ * IS a dispatcher route, and `publishHealthFromResponse` unwraps because the
608
+ * batch door really is enveloped. The rule is per-route, not per-file.
609
+ *
610
+ * ## The bundle member (objectui#12402)
611
+ *
612
+ * Pass `member` to publish one member of a type whose identity is a pair
613
+ * (today only `email_template`, `(name, locale)`), sent as `?member=` — the
614
+ * selector the by-name doors read since objectstack `038d4c958`, and the
615
+ * value a `GET /meta/_drafts` row carries as `member`. A publish that names
616
+ * none promotes the CANONICAL member's draft, so two languages of one
617
+ * template drafted outside a package cannot both be published by name
618
+ * without it. The parameter is OMITTED, never sent empty, when the caller
619
+ * has no member: the door refuses a blank selector, and refuses any
620
+ * selector on a type with no members.
283
621
  */
284
- async publishDraft(type, name) {
285
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
286
- const res = await this.fetchImpl(url, {
622
+ async publishDraft(type, name, options = {}) {
623
+ const params = [];
624
+ if (options.member) params.push(`member=${encodeURIComponent(options.member)}`);
625
+ const qs = params.length ? `?${params.join("&")}` : "";
626
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish${qs}`;
627
+ const res = await this.sendWrite(url, {
287
628
  method: "POST",
288
629
  headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
289
630
  body: "{}"
290
631
  });
291
632
  if (!res.ok) throw await parseError(res);
292
633
  const body = await res.json().catch(() => ({}));
293
- const inner = body?.data && typeof body.data === "object" ? body.data : body;
294
- return inner;
634
+ this.emitAdvisories(body, {
635
+ type,
636
+ name,
637
+ // objectui#12413 — the member this publish named, under the same rule as
638
+ // the query parameter above: present only when the caller had one.
639
+ ...options.member ? { member: options.member } : {},
640
+ door: "publish",
641
+ mode: "publish"
642
+ });
643
+ return body;
644
+ }
645
+ /**
646
+ * Publish EVERY pending draft bound to one package — Studio's "publish whole
647
+ * app" door, `POST /api/v1/packages/:id/publish-drafts` (ADR-0033; the route
648
+ * that orders structure-before-seeds server-side and runs the ADR-0038 L3
649
+ * runtime probes).
650
+ *
651
+ * ## Why it is expressed here at all (`ce986aafc`)
652
+ *
653
+ * It was not, and that was the defect. Two app-shell call sites fired this
654
+ * route with a bare `fetch` / a page-private `apiJson`, outside the seam that
655
+ * covers every other metadata write — so when objectstack#9343 landed and the
656
+ * server began sending per-draft advisories, the author publishing a whole
657
+ * app was told nothing, while the SAME button's client-side capability lint
658
+ * still raised a toast. A door that cannot report is not a door the gate can
659
+ * reach, however loudly the server speaks.
660
+ *
661
+ * ## What it reports, and what it refuses to invent
662
+ *
663
+ * `PublishPackageDraftsResponseSchema` declares `advisories` on EACH
664
+ * `published[]` element — objectstack#9343's ruled shape, the same element
665
+ * type and the same omitted-when-empty discipline as the single-item door,
666
+ * and explicitly not a parallel top-level map. So this method emits one
667
+ * {@link MetadataSaveAdvisoryEvent} per advised element, each naming that
668
+ * element's own `type` / `name`, through the same sink, event and renderer
669
+ * the save and single-item publish doors use.
670
+ *
671
+ * ⛔ It renders only what the server sent where the schema says it sits:
672
+ *
673
+ * - `advisories` is read off the element, by the one shared reader, which
674
+ * drops anything that is not a complete finding.
675
+ * - An element that does not carry the `type` and `name` the schema states as
676
+ * REQUIRED is skipped rather than reported under invented identity — the
677
+ * event names the item the author has to go fix, and an event that cannot
678
+ * name it truthfully is worse than silence.
679
+ * - Nothing is derived, counted or summarised from the batch: no advisory
680
+ * exists here that the server did not put in the body.
681
+ *
682
+ * ## The failure shapes stay the callers'
683
+ *
684
+ * Non-2xx throws {@link MetadataError}, like every other method. A 2xx is
685
+ * RETURNED unexamined — `success: false` is not a failure on this route
686
+ * (`outcome: 'nothing_to_publish'` answers it too, and the spec's own text
687
+ * says to read `outcome`, not the boolean), and the two callers have their
688
+ * own, different rules for the refusal and rolled-back cases. Deciding that
689
+ * here would change behaviour this card is not about.
690
+ *
691
+ * ## The opt-in backfill (objectui#12929)
692
+ *
693
+ * `options.backfill` is sent as the body's `backfill` key, as given, and only
694
+ * when the caller passes one ({@link MetadataPublishPackageDraftsOptions}).
695
+ * Without it the body is `{}`, byte for byte what every caller sent before,
696
+ * so a caller that names nothing writes nothing into existing rows. The
697
+ * receipt is `backfillApplied` on the returned body, unexamined like the rest
698
+ * of a 2xx: one element per named field with the rows written, and `error`
699
+ * on a field that stopped short (the publish itself stands).
700
+ */
701
+ async publishPackageDrafts(packageId, options = {}) {
702
+ if (!packageId || !String(packageId).trim()) {
703
+ throw new Error(
704
+ "MetadataClient.publishPackageDrafts: packageId must be non-empty. The POST /packages/:id/publish-drafts route requires an id segment."
705
+ );
706
+ }
707
+ const url = `${packagesBaseOf(this.base)}/${encodeURIComponent(packageId)}/publish-drafts`;
708
+ const res = await this.sendWrite(url, {
709
+ method: "POST",
710
+ // Both call sites this replaces sent the session cookie, and the client's
711
+ // own fetch adds the console's Bearer token — so the routed call carries a
712
+ // superset of what the bare doors carried, never less.
713
+ credentials: "include",
714
+ headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
715
+ body: JSON.stringify(options.backfill !== void 0 ? { backfill: options.backfill } : {})
716
+ });
717
+ if (!res.ok) throw await parseError(res);
718
+ const body = unwrapDispatcherEnvelope(await res.json().catch(() => ({})));
719
+ for (const element of Array.isArray(body.published) ? body.published : []) {
720
+ if (!element || typeof element !== "object") continue;
721
+ const { type, name } = element;
722
+ if (typeof type !== "string" || typeof name !== "string") continue;
723
+ this.emitAdvisories(element, { type, name, door: "publish", mode: "publish" });
724
+ }
725
+ return body;
295
726
  }
296
727
  /**
297
728
  * Get the 3-state layered view of a metadata item: `code` (the packaged
@@ -333,7 +764,7 @@ var MetadataClient = class _MetadataClient {
333
764
  }
334
765
  if (!res.ok) throw await parseError(res);
335
766
  const body = await res.json();
336
- const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
767
+ const hasEnvelope = typeof body === "object" && body !== null && ("code" in body || "overlay" in body || "effective" in body);
337
768
  if (!hasEnvelope) {
338
769
  return {
339
770
  code: null,
@@ -342,14 +773,34 @@ var MetadataClient = class _MetadataClient {
342
773
  effective: body
343
774
  };
344
775
  }
776
+ const parsed = GetMetaItemLayeredResponseSchema.safeParse(body);
777
+ if (parsed.success) {
778
+ const envelope = parsed.data;
779
+ return {
780
+ code: envelope.code ?? null,
781
+ overlay: envelope.overlay ?? null,
782
+ overlayScope: envelope.overlayScope,
783
+ effective: envelope.effective ?? null,
784
+ ...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
785
+ lock: envelope.lock,
786
+ ...envelope.lockReason !== void 0 ? { lockReason: envelope.lockReason } : {},
787
+ ...envelope.lockSource !== void 0 ? { lockSource: envelope.lockSource } : {},
788
+ ...envelope.lockDocsUrl !== void 0 ? { lockDocsUrl: envelope.lockDocsUrl } : {},
789
+ ...envelope.provenance !== void 0 ? { provenance: envelope.provenance } : {},
790
+ ...envelope.packageId !== void 0 ? { packageId: envelope.packageId } : {},
791
+ ...envelope.packageVersion !== void 0 ? { packageVersion: envelope.packageVersion } : {},
792
+ editable: envelope.editable,
793
+ deletable: envelope.deletable,
794
+ resettable: envelope.resettable
795
+ };
796
+ }
797
+ const unrecognized = unrecognizedEnvelopeKeys(body);
345
798
  return {
346
799
  code: body.code ?? null,
347
800
  overlay: body.overlay ?? null,
348
801
  overlayScope: body.overlayScope ?? null,
349
802
  effective: body.effective ?? null,
350
803
  ...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
804
  ...body.lock !== void 0 ? { lock: body.lock } : {},
354
805
  ...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
355
806
  ...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
@@ -359,7 +810,8 @@ var MetadataClient = class _MetadataClient {
359
810
  ...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
360
811
  ...body.editable !== void 0 ? { editable: body.editable } : {},
361
812
  ...body.deletable !== void 0 ? { deletable: body.deletable } : {},
362
- ...body.resettable !== void 0 ? { resettable: body.resettable } : {}
813
+ ...body.resettable !== void 0 ? { resettable: body.resettable } : {},
814
+ ...unrecognized.length > 0 ? { _unrecognized: unrecognized } : {}
363
815
  };
364
816
  }
365
817
  /**
@@ -410,13 +862,22 @@ var MetadataClient = class _MetadataClient {
410
862
  * Idempotent: returns the result even when no overlay row existed.
411
863
  * Pass `state: 'draft'` to discard the pending draft only (keeps the
412
864
  * published overlay intact) — useful for a Studio "Discard draft" button.
865
+ *
866
+ * Pass `packageId` to name the package whose row the delete removes, sent
867
+ * as `?package=`. A delete that names none addresses the one package that
868
+ * holds the item; when two or more packages hold it the door refuses it
869
+ * `409 RESOURCE_CONFLICT`, deletes nothing, and names `?package=` as the
870
+ * remedy. That refusal is thrown like any other {@link MetadataError}.
413
871
  */
414
872
  async reset(type, name, options = {}) {
415
- const qs = options.state === "draft" ? "?state=draft" : "";
873
+ const params = [];
874
+ if (options.state === "draft") params.push("state=draft");
875
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
876
+ const qs = params.length ? `?${params.join("&")}` : "";
416
877
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
417
878
  const headers = { ...this.headers };
418
879
  if (options.ifMatch) headers["If-Match"] = options.ifMatch;
419
- const res = await this.fetchImpl(url, { method: "DELETE", headers });
880
+ const res = await this.sendWrite(url, { method: "DELETE", headers });
420
881
  if (!res.ok) throw await parseError(res);
421
882
  return await res.json();
422
883
  }
@@ -426,20 +887,29 @@ var MetadataClient = class _MetadataClient {
426
887
  * `{ success, version, seq, message }`. Throws a `404 no_draft` if
427
888
  * nothing is pending and `409 metadata_conflict` if the published
428
889
  * overlay moved while the draft was sitting.
890
+ *
891
+ * Reports the runtime authoring gate's advisory findings through
892
+ * {@link MetadataClientConfig.onSaveAdvisory} when the response carries them
893
+ * (#5026 / objectstack#9176), with `door: 'publish'`. This is the door the
894
+ * Studio designer takes on every edit, so it is the one whose findings an
895
+ * author actually sees.
429
896
  */
430
897
  async publish(type, name, options = {}) {
431
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
898
+ const qs = options.packageId ? `?package=${encodeURIComponent(options.packageId)}` : "";
899
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish${qs}`;
432
900
  const headers = {
433
901
  ...this.headers,
434
902
  "Content-Type": "application/json"
435
903
  };
436
- const res = await this.fetchImpl(url, {
904
+ const res = await this.sendWrite(url, {
437
905
  method: "POST",
438
906
  headers,
439
907
  body: JSON.stringify(options.message ? { message: options.message } : {})
440
908
  });
441
909
  if (!res.ok) throw await parseError(res);
442
- return await res.json();
910
+ const body = await res.json();
911
+ this.emitAdvisories(body, { type, name, door: "publish", mode: "publish" });
912
+ return body;
443
913
  }
444
914
  /**
445
915
  * Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
@@ -455,7 +925,7 @@ var MetadataClient = class _MetadataClient {
455
925
  ...this.headers,
456
926
  "Content-Type": "application/json"
457
927
  };
458
- const res = await this.fetchImpl(url, {
928
+ const res = await this.sendWrite(url, {
459
929
  method: "POST",
460
930
  headers,
461
931
  body: JSON.stringify({
@@ -534,15 +1004,38 @@ function errorCodeIsAnyOf(err, codes) {
534
1004
  }
535
1005
 
536
1006
  // ../core/src/utils/filter-converter.ts
537
- import { normalizeFilterOperator } from "@objectstack/spec/ui";
1007
+ import {
1008
+ normalizeFilterOperator,
1009
+ VIEW_FILTER_OPERATORS,
1010
+ VIEW_FILTER_LIST_VALUE_OPERATORS,
1011
+ VIEW_FILTER_PAIR_VALUE_OPERATORS
1012
+ } from "@objectstack/spec/ui";
1013
+ import { isAcceptedFilterComparand, ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE } from "@objectstack/spec/data";
1014
+
1015
+ // ../core/src/utils/text-comparand.ts
1016
+ import { isRefusedTextComparand, textComparandRefusalReason } from "@objectstack/spec/data";
1017
+
1018
+ // ../core/src/utils/filter-converter.ts
538
1019
  var FilterOperatorError = class extends Error {
539
- constructor(message) {
1020
+ constructor(message, subject) {
540
1021
  super(message);
541
1022
  __publicField(this, "code", "INVALID_FILTER");
542
1023
  __publicField(this, "httpStatus", 400);
1024
+ /** @see FilterRefusalSubject */
1025
+ __publicField(this, "operator");
1026
+ /** @see FilterRefusalSubject */
1027
+ __publicField(this, "field");
543
1028
  this.name = "FilterOperatorError";
1029
+ this.operator = subject?.operator;
1030
+ this.field = subject?.field;
544
1031
  }
545
1032
  };
1033
+ var RETIRED_OPERATOR_ALIASES = {
1034
+ "$notin": "$nin",
1035
+ "$notcontains": "$notContains",
1036
+ "$startswith": "$startsWith",
1037
+ "$endswith": "$endsWith"
1038
+ };
546
1039
  function convertOperatorToAST(operator) {
547
1040
  const operatorMap = {
548
1041
  "$eq": "=",
@@ -553,27 +1046,148 @@ function convertOperatorToAST(operator) {
553
1046
  "$lte": "<=",
554
1047
  "$in": "in",
555
1048
  "$nin": "nin",
556
- "$notin": "nin",
557
1049
  "$between": "between",
558
1050
  "$contains": "contains",
559
1051
  "$notContains": "notcontains",
560
- "$notcontains": "notcontains",
561
1052
  "$startsWith": "startswith",
562
- "$startswith": "startswith",
563
1053
  "$endsWith": "endswith",
564
- "$endswith": "endswith"
1054
+ // Case-insensitive contains. A canonical `FILTER_OPERATORS` member that
1055
+ // `ValueDataSource` executes and `FilterConditionField` emits (for its
1056
+ // `icontains` builder row — spelled `containsCaseInsensitive` until
1057
+ // objectui#9306), while this map refused it with the
1058
+ // generic unknown-operator paragraph — so ONE authored filter selected rows
1059
+ // through the in-memory matcher and 400'd on the ObjectStack lowering path
1060
+ // (objectui#8976). The other direction of the same split objectui#8568 fixed:
1061
+ // there the map was MORE tolerant than the matcher, here it was LESS tolerant
1062
+ // than the contract. Restored by aligning the map with the spec, per AGENTS.md
1063
+ // #0 — not by removing the operator from the builder, which the spec declares.
1064
+ //
1065
+ // The VALUE is an IDENTITY, and that is not a typo: `icontains` is itself a
1066
+ // member of the spec's `VALID_AST_OPERATORS`, so unlike its camelCase siblings
1067
+ // above there is no case to squash. Same identity row, for the same stated
1068
+ // reason, that `FILTER_OPERATOR_ALIASES` carries in
1069
+ // `packages/data-objectstack/src/index.ts`.
1070
+ "$icontains": "icontains"
565
1071
  };
566
1072
  return operatorMap[operator] || null;
567
1073
  }
1074
+ var AST_LOGIC_KEYWORD = {
1075
+ $and: "and",
1076
+ $or: "or"
1077
+ };
1078
+ function lowerLogicalGroup(field, keyword, value) {
1079
+ if (!Array.isArray(value)) {
1080
+ throw new FilterOperatorError(
1081
+ `[ObjectUI] The '${field}' filter combinator takes an ARRAY of conditions. Received ${typeof value === "object" ? "an object" : typeof value}: ${JSON.stringify(value)}. Spec: FilterCondition declares '${field}?: FilterCondition[]' (data/filter.zod.ts).`,
1082
+ { operator: field }
1083
+ );
1084
+ }
1085
+ if (value.length === 0) {
1086
+ return keyword === "and" ? void 0 : falseIdentityLeaf(field, value);
1087
+ }
1088
+ const children = [];
1089
+ let absorbed = false;
1090
+ for (const child of value) {
1091
+ if (child === null || typeof child !== "object" || Array.isArray(child)) {
1092
+ throw new FilterOperatorError(
1093
+ `[ObjectUI] Every member of '${field}' must be a filter condition OBJECT. Received ${JSON.stringify(child)}. Spec: FilterCondition declares '${field}?: FilterCondition[]' (data/filter.zod.ts).`,
1094
+ { operator: field }
1095
+ );
1096
+ }
1097
+ const lowered = convertFiltersToAST(child);
1098
+ if (!Array.isArray(lowered)) {
1099
+ if (keyword === "or") absorbed = true;
1100
+ continue;
1101
+ }
1102
+ children.push(lowered);
1103
+ }
1104
+ if (absorbed) return void 0;
1105
+ if (children.length === 0) return void 0;
1106
+ return [keyword, ...children];
1107
+ }
1108
+ function falseIdentityLeaf(field, value) {
1109
+ return [field, "=", value];
1110
+ }
1111
+ function isOperatorMap(value) {
1112
+ const proto = Object.getPrototypeOf(value);
1113
+ return proto === null || proto === Object.prototype || Object.getPrototypeOf(proto) === null;
1114
+ }
1115
+ function describeExoticComparand(value) {
1116
+ let name;
1117
+ let text2;
1118
+ try {
1119
+ const proto = Object.getPrototypeOf(value);
1120
+ name = proto?.constructor?.name;
1121
+ } catch {
1122
+ name = void 0;
1123
+ }
1124
+ if (typeof name !== "string" || name === "") return "an object of an unrecognised type";
1125
+ try {
1126
+ text2 = String(value);
1127
+ } catch {
1128
+ text2 = void 0;
1129
+ }
1130
+ return text2 && text2 !== `[object ${name}]` ? `a ${name} instance (${text2})` : `a ${name} instance`;
1131
+ }
1132
+ function refuseTextComparand(field, operator, target) {
1133
+ const ported = `It is refused here rather than lowered onto the wire (objectui#9001; ported from ValueDataSource's refuseTextComparand, objectui#8748).`;
1134
+ throw new FilterOperatorError(
1135
+ `[ObjectUI] The ${textComparandRefusalReason(field, operator, target)}. ${ported}`,
1136
+ { operator, field }
1137
+ );
1138
+ }
1139
+ function refuseEmptyOperatorMap(field) {
1140
+ throw new FilterOperatorError(
1141
+ `[ObjectUI] The filter on field '${field}' is an EMPTY operator map \u2014 { ${field}: {} } names the field and no operator. It cannot be lowered: the ObjectQL filter AST has no node for a field with zero operators, and '@objectstack/spec' ruled the shape REJECTED wherever it appears (objectstack#5240, recorded on FilterConditionSchema). It is refused here rather than dropped: beside a key that lowers, the '${field}' condition used to vanish from the filter and silently WIDEN the result set (objectui#10788), and alone it was handed back as the caller's object, which reached the wire as ?${field}=[object Object] on one route and as a filter the server refuses on the other (objectui#9164). Choose an operator \u2014 { ${field}: { $eq: ... } }, { ${field}: { $in: [...] } }, { ${field}: { $null: true } } \u2014 or remove the key.`,
1142
+ { field }
1143
+ );
1144
+ }
568
1145
  function convertFiltersToAST(filter) {
569
1146
  const conditions = [];
1147
+ let trueIdentityGroups = 0;
1148
+ let skippedNullKeys = 0;
570
1149
  for (const [field, value] of Object.entries(filter)) {
571
- if (value === null || value === void 0) continue;
1150
+ if (value === null || value === void 0) {
1151
+ skippedNullKeys += 1;
1152
+ continue;
1153
+ }
1154
+ const logicKeyword = AST_LOGIC_KEYWORD[field];
1155
+ if (logicKeyword) {
1156
+ const group = lowerLogicalGroup(field, logicKeyword, value);
1157
+ if (group !== void 0) conditions.push(group);
1158
+ else trueIdentityGroups += 1;
1159
+ continue;
1160
+ }
1161
+ if (field === "$not") {
1162
+ throw new FilterOperatorError(
1163
+ `[ObjectUI] The '$not' filter combinator cannot be lowered to the ObjectQL filter AST. '@objectstack/spec' declares it on FilterCondition, but the AST this layer emits has no negation keyword (FILTER_ARRAY_LOGIC_KEYWORDS is ['and', 'or']), and rewriting the negation inward is not available either \u2014 'startswith', 'endswith', 'between' and 'icontains' have no negated counterpart in VALID_AST_OPERATORS, so the rewrite would be silently partial. Express the negation with a negated operator instead ($ne, $nin, $notContains); note those follow each operator's own answer for a missing value rather than $not's NULL-safe rule (objectstack#5146). Value: ${JSON.stringify(value)}.`,
1164
+ { operator: "$not" }
1165
+ );
1166
+ }
1167
+ if (Array.isArray(value)) {
1168
+ throw new FilterOperatorError(
1169
+ `[ObjectUI] The filter on field '${field}' carries a bare ARRAY as its equality comparand: ${JSON.stringify(value)}. It cannot be lowered: the ObjectQL filter AST has no array-equality node, so the lowered node [${field}, '=', [...]] is refused by @objectstack/driver-sql (400 INVALID_FILTER) and matches no row in the in-memory matchers \u2014 it can never select anything. It is deliberately NOT read as membership here: { ${field}: [...] } and { ${field}: { $in: [...] } } are different statements, and guessing the second from the first would silently change which rows a stored view returns (objectui#8530; the same ruling objectui#8514 applied one layer down). Spell membership as { ${field}: { $in: [...] } }, its negation as { ${field}: { $nin: [...] } }, or a range as { ${field}: { $between: [min, max] } }.`,
1170
+ { field }
1171
+ );
1172
+ }
572
1173
  if (typeof value === "object" && !Array.isArray(value)) {
573
- for (const [operator, operatorValue] of Object.entries(value)) {
1174
+ if (isAcceptedFilterComparand(value)) {
1175
+ conditions.push([field, "=", value]);
1176
+ continue;
1177
+ }
1178
+ if (!isOperatorMap(value)) {
1179
+ throw new FilterOperatorError(
1180
+ `[ObjectUI] The filter on field '${field}' carries ${describeExoticComparand(value)} in comparand position. '@objectstack/spec' does not accept it: a comparison value must be ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (ACCEPTED_FILTER_COMPARAND_TYPES), so isAcceptedFilterComparand is false for it and normalizeFilterComparandTypes answers 400 INVALID_FILTER \u2014 "which no driver can compare". It is not an operator map either: only a plain object can be one, so until objectui#8567 this value was read as ZERO operators and the '${field}' condition was dropped from the filter entirely, silently WIDENING the result set. Spell a text match as { ${field}: { $contains: '...' } } ($startsWith / $endsWith; a pattern itself has no operator here, see the $regex refusal), a membership test as { ${field}: { $in: [...] } }, and a date bound as { ${field}: { $gte: new Date(...) } }.`,
1181
+ { field }
1182
+ );
1183
+ }
1184
+ const operatorEntries = Object.entries(value);
1185
+ if (operatorEntries.length === 0) refuseEmptyOperatorMap(field);
1186
+ for (const [operator, operatorValue] of operatorEntries) {
574
1187
  if (operator === "$regex") {
575
1188
  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.`
1189
+ `[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 for a case-sensitive substring, $icontains for a case-insensitive one, or $startsWith / $endsWith.`,
1190
+ { operator: "$regex", field }
577
1191
  );
578
1192
  }
579
1193
  if (operator === "$null") {
@@ -584,12 +1198,33 @@ function convertFiltersToAST(filter) {
584
1198
  conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
585
1199
  continue;
586
1200
  }
1201
+ if (operator === "$empty") {
1202
+ if (operatorValue !== true && operatorValue !== false) {
1203
+ throw new FilterOperatorError(
1204
+ `[ObjectUI] The '$empty' filter operator on field '${field}' takes a boolean flag (true or false); received ${JSON.stringify(operatorValue) ?? String(operatorValue)}. Write { ${field}: { $empty: true } } for an empty value, or { ${field}: { $empty: false } } for a non-empty one.`,
1205
+ { operator: "$empty", field }
1206
+ );
1207
+ }
1208
+ conditions.push([field, operatorValue ? "is_empty" : "is_not_empty", true]);
1209
+ continue;
1210
+ }
587
1211
  const astOperator = convertOperatorToAST(operator);
588
1212
  if (astOperator) {
1213
+ if (astOperator === "icontains" && isRefusedTextComparand(operatorValue)) {
1214
+ refuseTextComparand(field, operator, operatorValue);
1215
+ }
589
1216
  conditions.push([field, astOperator, operatorValue]);
590
1217
  } else {
1218
+ const canonical = RETIRED_OPERATOR_ALIASES[operator];
1219
+ if (canonical) {
1220
+ throw new FilterOperatorError(
1221
+ `[ObjectUI] The '${operator}' filter operator is retired. Write '${canonical}' instead \u2014 the canonical spelling '@objectstack/spec' declares in FILTER_OPERATORS (data/filter.zod.ts). The operator itself is unchanged; only the key is renamed. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. It used to be accepted here as a lowercase alias while the in-memory matcher refused it, so the same filter selected rows through one data source and none through the other (objectui#8568).`,
1222
+ { operator, field }
1223
+ );
1224
+ }
591
1225
  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.`
1226
+ `[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, $notContains, $startsWith, $endsWith, $icontains, $null, $exists, $empty. If you need exact object matching, use the value directly without an operator.`,
1227
+ { operator, field }
593
1228
  );
594
1229
  }
595
1230
  }
@@ -598,6 +1233,13 @@ function convertFiltersToAST(filter) {
598
1233
  }
599
1234
  }
600
1235
  if (conditions.length === 0) {
1236
+ const keysTheLoopProcessed = Object.keys(filter).length - skippedNullKeys;
1237
+ if (trueIdentityGroups > 0 && trueIdentityGroups === keysTheLoopProcessed) {
1238
+ return void 0;
1239
+ }
1240
+ if (skippedNullKeys > 0 && skippedNullKeys === Object.keys(filter).length) {
1241
+ return void 0;
1242
+ }
601
1243
  return filter;
602
1244
  }
603
1245
  if (conditions.length === 1) {
@@ -605,6 +1247,52 @@ function convertFiltersToAST(filter) {
605
1247
  }
606
1248
  return ["and", ...conditions];
607
1249
  }
1250
+ var ARRAY_VALUED_VIEW_OPERATORS = /* @__PURE__ */ new Set([
1251
+ ...VIEW_FILTER_LIST_VALUE_OPERATORS,
1252
+ ...VIEW_FILTER_PAIR_VALUE_OPERATORS
1253
+ ]);
1254
+ var KNOWN_VIEW_OPERATORS = new Set(VIEW_FILTER_OPERATORS);
1255
+
1256
+ // ../core/src/utils/column-sortability.ts
1257
+ import {
1258
+ FIELD_UNSORTABLE_VIRTUAL_TYPE,
1259
+ FIELD_SORTABLE_UNPROVISIONED_ANCHOR
1260
+ } from "@objectstack/spec/api";
1261
+ var OBJECT_SORTABILITY_KEY = /* @__PURE__ */ Symbol.for("objectui.objectSortability");
1262
+ function asFieldSortability(value) {
1263
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
1264
+ const sortable = value.sortable;
1265
+ if (typeof sortable !== "boolean") return void 0;
1266
+ const reason = value.reason;
1267
+ const caveat = value.caveat;
1268
+ return {
1269
+ sortable,
1270
+ ...typeof reason === "string" ? { reason } : {},
1271
+ ...typeof caveat === "string" ? { caveat } : {}
1272
+ };
1273
+ }
1274
+ function normalizeObjectSortability(served) {
1275
+ if (!served || typeof served !== "object" || Array.isArray(served)) return void 0;
1276
+ const rawFields = served.fields;
1277
+ if (!rawFields || typeof rawFields !== "object" || Array.isArray(rawFields)) return void 0;
1278
+ const fields = {};
1279
+ for (const [name, entry] of Object.entries(rawFields)) {
1280
+ const verdict = asFieldSortability(entry);
1281
+ if (verdict) fields[name] = verdict;
1282
+ }
1283
+ return { fields };
1284
+ }
1285
+ function attachObjectSortability(schema, served) {
1286
+ const projection = normalizeObjectSortability(served);
1287
+ if (!projection || !schema || typeof schema !== "object") return schema;
1288
+ Object.defineProperty(schema, OBJECT_SORTABILITY_KEY, {
1289
+ value: projection,
1290
+ enumerable: false,
1291
+ configurable: true,
1292
+ writable: true
1293
+ });
1294
+ return schema;
1295
+ }
608
1296
 
609
1297
  // ../core/src/adapters/batchTransaction.ts
610
1298
  function idOf(rec) {
@@ -673,20 +1361,143 @@ async function emulateBatchTransaction(dataSource, operations) {
673
1361
  }
674
1362
 
675
1363
  // ../core/src/utils/reference-keys.ts
676
- function normalizeFieldReferenceKeys(fieldDef) {
1364
+ import { FIELD_KEY_GUIDANCE, FieldSchema } from "@objectstack/spec/data";
1365
+ var aliasProbe = (key) => key.toLowerCase().replace(/[_\-\s]/g, "");
1366
+ var declaredFieldKeys = null;
1367
+ var probeFolds = null;
1368
+ var collidingProbes = null;
1369
+ function fieldKeyFolds() {
1370
+ if (probeFolds) return probeFolds;
1371
+ const declared = Object.keys(FieldSchema.shape);
1372
+ const seen = /* @__PURE__ */ new Map();
1373
+ for (const key of declared) {
1374
+ const probe = aliasProbe(key);
1375
+ seen.set(probe, [...seen.get(probe) ?? [], key]);
1376
+ }
1377
+ const folds = /* @__PURE__ */ new Map();
1378
+ const collisions = /* @__PURE__ */ new Map();
1379
+ for (const [probe, keys] of seen) {
1380
+ if (keys.length === 1) folds.set(probe, keys[0]);
1381
+ else collisions.set(probe, keys);
1382
+ }
1383
+ declaredFieldKeys = new Set(declared);
1384
+ collidingProbes = collisions;
1385
+ probeFolds = folds;
1386
+ return folds;
1387
+ }
1388
+ var warnedRetiredSpelling = /* @__PURE__ */ new Set();
1389
+ var warnedUnfoldableSpelling = /* @__PURE__ */ new Set();
1390
+ function warnUnfoldableRetiredKey(reason, key, detail, fieldName, objectName, f) {
1391
+ if (!isDev()) return;
1392
+ const named = fieldName ?? (typeof f.name === "string" ? f.name : "(unnamed field)");
1393
+ const owner = objectName ?? "(unknown object)";
1394
+ const memo = `${owner}:${named}:${key}:${reason}`;
1395
+ if (warnedUnfoldableSpelling.has(memo)) return;
1396
+ const published = reason === "no-declared-twin" && key === "id_field" ? ` \`@objectstack/spec\`'s \`FIELD_KEY_GUIDANCE\` gives the reason: ${FIELD_KEY_GUIDANCE.id_field.why}` : "";
1397
+ warnedUnfoldableSpelling.add(memo);
1398
+ console.warn(
1399
+ `[ObjectUI] Object \`${owner}\`, field \`${named}\`: the ingestion choke point CANNOT canonicalize the retired spelling \`${key}\`. ${detail}${published} The key and its value are LEFT on the def exactly as served \u2014 nothing is dropped here \u2014 but a consumer that reads only the spellings \`@objectstack/spec\`'s \`FieldSchema\` declares will not see this value. Fix the PRODUCER, or migrate the stored document. (maintainer ruling item 3 on objectui#7650; objectui#8938)`
1400
+ );
1401
+ }
1402
+ function canonicalizeRetiredFieldKeys(f, fieldName, objectName) {
1403
+ const folds = fieldKeyFolds();
1404
+ for (const key of Object.keys(f)) {
1405
+ if (declaredFieldKeys.has(key)) continue;
1406
+ const value = f[key];
1407
+ if (value === void 0) continue;
1408
+ if (REFERENCE_ARM_KEYS.has(key)) continue;
1409
+ const probe = aliasProbe(key);
1410
+ const canonical = folds.get(probe);
1411
+ if (canonical === void 0) {
1412
+ const colliding = collidingProbes.get(probe);
1413
+ if (colliding) {
1414
+ warnUnfoldableRetiredKey(
1415
+ "ambiguous-probe",
1416
+ key,
1417
+ `Its alias spelling is shared by MORE THAN ONE key \`FieldSchema\` declares (${colliding.map((c) => `\`${c}\``).join(", ")}), so the fold refuses to choose between them rather than picking one.`,
1418
+ fieldName,
1419
+ objectName,
1420
+ f
1421
+ );
1422
+ } else {
1423
+ warnUnfoldableRetiredKey(
1424
+ "no-declared-twin",
1425
+ key,
1426
+ `\`FieldSchema\` declares neither \`${key}\` nor any key sharing its alias spelling (lowercased, with \`_\`, \`-\` and spaces removed), so there is no canonical key to fold it onto \u2014 and this path deliberately does not guess a near match.`,
1427
+ fieldName,
1428
+ objectName,
1429
+ f
1430
+ );
1431
+ }
1432
+ continue;
1433
+ }
1434
+ if (f[canonical] !== void 0) {
1435
+ if (f[canonical] !== value) {
1436
+ warnUnfoldableRetiredKey(
1437
+ "canonical-occupied",
1438
+ key,
1439
+ `\`FieldSchema\` declares \`${canonical}\`, and this def already carries a DIFFERENT value under it; the producer's value stands, because this choke point never overwrites one.`,
1440
+ fieldName,
1441
+ objectName,
1442
+ f
1443
+ );
1444
+ }
1445
+ continue;
1446
+ }
1447
+ f[canonical] = value;
1448
+ if (!isDev()) continue;
1449
+ const named = fieldName ?? (typeof f.name === "string" ? f.name : "(unnamed field)");
1450
+ const owner = objectName ?? "(unknown object)";
1451
+ const memo = `${owner}:${named}:${key}:${canonical}`;
1452
+ if (warnedRetiredSpelling.has(memo)) continue;
1453
+ warnedRetiredSpelling.add(memo);
1454
+ console.warn(
1455
+ `[ObjectUI] Object \`${owner}\`, field \`${named}\` carries the retired spelling \`${key}\`. \`@objectstack/spec\`'s \`FieldSchema\` declares \`${canonical}\` and does not declare \`${key}\`, so a producer emitting it would be refused at the metadata write door. ObjectUI stamps \`${canonical}\` here so this stored def still renders, but the consumers no longer carry a \`${key}\` fallback of their own \u2014 fix the PRODUCER, or migrate the stored document. (objectui#7650)`
1456
+ );
1457
+ }
1458
+ }
1459
+ var warnedLegacyOnly = /* @__PURE__ */ new Set();
1460
+ var isDev = () => globalThis.process?.env?.NODE_ENV !== "production";
1461
+ var LEGACY_REFERENCE_KEYS = ["reference_to", "referenceTo"];
1462
+ var REFERENCE_ARM_KEYS = new Set(LEGACY_REFERENCE_KEYS);
1463
+ function warnOnLegacyOnlyReference(f, fieldName, objectName) {
1464
+ if (!isDev()) return;
1465
+ if (f.reference !== void 0) return;
1466
+ const spelled = LEGACY_REFERENCE_KEYS.filter((k) => f[k] !== void 0 && f[k] !== "");
1467
+ if (spelled.length === 0) return;
1468
+ const named = fieldName ?? (typeof f.name === "string" ? f.name : "(unnamed field)");
1469
+ const owner = objectName ?? "(unknown object)";
1470
+ for (const key of spelled) {
1471
+ const memo = `${owner}:${named}:${key}:${String(f[key])}`;
1472
+ if (warnedLegacyOnly.has(memo)) continue;
1473
+ warnedLegacyOnly.add(memo);
1474
+ console.warn(
1475
+ `[ObjectUI] Object \`${owner}\`, field \`${named}\` declares its relationship target as \`${key}: '${String(f[key])}'\` and does NOT carry \`reference\`. \`reference\` is the only spelling the protocol declares: \`@objectstack/spec\`'s \`FieldSchema\` refuses \`${key}\` by name with \`unrecognized_keys\` ("Did you mean \`${key}\` -> \`reference\`?"). ObjectUI stamps \`reference\` here so this def still renders, but the readers no longer carry a \`${key}\` fallback of their own \u2014 fix the PRODUCER to emit \`reference\`, or serve the def through a backend that normalizes it (objectstack#13847 does this on the serve path and in \`os migrate meta\`). Maintainer ruling 2026-08-31: protocol normalization belongs on the server, the front end just executes the protocol. (objectui#6837)`
1476
+ );
1477
+ }
1478
+ }
1479
+ function normalizeFieldReferenceKeys(fieldDef, fieldName, objectName) {
677
1480
  if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
678
1481
  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;
1482
+ canonicalizeRetiredFieldKeys(f, fieldName, objectName);
1483
+ if (f.reference !== void 0) return fieldDef;
1484
+ const legacy = f.reference_to ?? f.referenceTo;
1485
+ if (legacy == null || legacy === "") return fieldDef;
1486
+ warnOnLegacyOnlyReference(f, fieldName, objectName);
1487
+ f.reference = legacy;
683
1488
  return fieldDef;
684
1489
  }
685
1490
  function normalizeSchemaReferenceKeys(schema) {
686
1491
  const fields = schema && typeof schema === "object" ? schema.fields : null;
687
1492
  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);
1493
+ const objectName = typeof schema.name === "string" ? schema.name : void 0;
1494
+ if (Array.isArray(fields)) {
1495
+ for (const def of fields) normalizeFieldReferenceKeys(def, void 0, objectName);
1496
+ } else {
1497
+ for (const [name, def] of Object.entries(fields)) {
1498
+ normalizeFieldReferenceKeys(def, name, objectName);
1499
+ }
1500
+ }
690
1501
  return schema;
691
1502
  }
692
1503
 
@@ -739,21 +1550,54 @@ var MetadataCache = class {
739
1550
  this.cache.delete(key);
740
1551
  }
741
1552
  }
1553
+ return this.load(key, fetcher);
1554
+ }
1555
+ /**
1556
+ * Join the read in flight for `key`, or start one, and never answer from the
1557
+ * settled entry: a caller of this always gets an answer from a request that
1558
+ * was on the wire when it asked.
1559
+ *
1560
+ * objectui#13044. This is the door for a caller whose own contract is "ask
1561
+ * the server", which should still not put a second identical request on the
1562
+ * wire while one is already there.
1563
+ *
1564
+ * objectui#13057. The landing is stored, exactly as one {@link get} started
1565
+ * would store it, so a later {@link get} for `key` may be answered from it.
1566
+ * The caller asked the server; a reader that may be served what the cache
1567
+ * already holds is served the answer that caller just received.
1568
+ */
1569
+ share(key, fetcher) {
1570
+ return this.load(key, fetcher);
1571
+ }
1572
+ /**
1573
+ * The in-flight half of {@link get} and {@link share}: join the request in
1574
+ * flight for `key`, or start one.
1575
+ *
1576
+ * A landing is stored, and its in-flight entry cleared, only
1577
+ * while it is still the request registered for `key` (objectui#13044). {@link invalidate}
1578
+ * and {@link clear} detach the request in flight, so a request sent before an
1579
+ * invalidation can neither answer a caller who asks after it nor write its
1580
+ * answer over a newer one.
1581
+ */
1582
+ load(key, fetcher) {
742
1583
  const existing = this.inflight.get(key);
743
1584
  if (existing) {
744
1585
  this.stats.coalesced++;
745
1586
  return existing;
746
1587
  }
747
1588
  this.stats.misses++;
748
- const promise = (async () => {
1589
+ const self = {};
1590
+ const run = async () => {
749
1591
  try {
750
1592
  const data = await fetcher();
751
- this.set(key, data);
1593
+ if (this.inflight.get(key) === self.promise) this.set(key, data);
752
1594
  return data;
753
1595
  } finally {
754
- this.inflight.delete(key);
1596
+ if (this.inflight.get(key) === self.promise) this.inflight.delete(key);
755
1597
  }
756
- })();
1598
+ };
1599
+ const promise = run();
1600
+ self.promise = promise;
757
1601
  this.inflight.set(key, promise);
758
1602
  return promise;
759
1603
  }
@@ -801,8 +1645,10 @@ var MetadataCache = class {
801
1645
  invalidate(key) {
802
1646
  if (key) {
803
1647
  this.cache.delete(key);
1648
+ this.inflight.delete(key);
804
1649
  } else {
805
1650
  this.cache.clear();
1651
+ this.inflight.clear();
806
1652
  }
807
1653
  }
808
1654
  /**
@@ -872,6 +1718,21 @@ var MetadataCache = class {
872
1718
  }
873
1719
  };
874
1720
 
1721
+ // src/metadata-error.ts
1722
+ function extractIssues(e) {
1723
+ const issues = e?.issues;
1724
+ return Array.isArray(issues) ? issues : [];
1725
+ }
1726
+ function formatMetadataIssue(i) {
1727
+ return `\u2022 ${i.path && i.path.length > 0 ? i.path : "(root)"} \u2014 ${i.message}`;
1728
+ }
1729
+ function formatMetadataError(e) {
1730
+ const issues = extractIssues(e);
1731
+ if (issues.length > 0) return issues.map(formatMetadataIssue).join("\n");
1732
+ const err = e;
1733
+ return err?.message ?? String(e);
1734
+ }
1735
+
875
1736
  // src/errors.ts
876
1737
  var ObjectStackError = class extends Error {
877
1738
  /**
@@ -1059,53 +1920,88 @@ function isErrorType(error, errorClass) {
1059
1920
  return error instanceof errorClass;
1060
1921
  }
1061
1922
 
1923
+ // src/error-envelope.ts
1924
+ function readErrorEnvelope(body) {
1925
+ if (!body || typeof body !== "object") return {};
1926
+ const env = body;
1927
+ const nested = env.error && typeof env.error === "object" ? env.error : void 0;
1928
+ const reading = {};
1929
+ const message = text(nested?.message) ?? text(env.message) ?? text(env.error);
1930
+ if (message !== void 0) reading.message = message;
1931
+ const code = present(env.code) ?? present(nested?.code);
1932
+ if (code !== void 0) reading.code = code;
1933
+ return reading;
1934
+ }
1935
+ function text(value) {
1936
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
1937
+ }
1938
+ function present(value) {
1939
+ return typeof value === "string" ? value : void 0;
1940
+ }
1941
+
1062
1942
  // src/userState.ts
1063
1943
  var DEFAULT_RESOURCE = "sys_user_preference";
1064
- function createObjectStackUserStateAdapter(options) {
1065
- const {
1066
- dataSource,
1067
- userId,
1068
- key,
1069
- resource = DEFAULT_RESOURCE,
1070
- onError = () => {
1944
+ var PREFERENCE_FAMILY_ROW_LIMIT = 200;
1945
+ function preferenceQuery(userId, keys, prefixes = []) {
1946
+ if (prefixes.length === 0) {
1947
+ if (keys.length === 1) {
1948
+ return { $filter: { user_id: userId, key: keys[0] }, $top: 1 };
1071
1949
  }
1072
- } = options;
1950
+ return { $filter: { user_id: userId, key: { $in: [...keys] } }, $top: keys.length };
1951
+ }
1952
+ const arms = [];
1953
+ if (keys.length === 1) arms.push({ key: keys[0] });
1954
+ else if (keys.length > 1) arms.push({ key: { $in: [...keys] } });
1955
+ for (const prefix of prefixes) arms.push({ key: { $startsWith: prefix } });
1956
+ return {
1957
+ $filter: { user_id: userId, ...arms.length === 1 ? arms[0] : { $or: arms } },
1958
+ $top: keys.length + prefixes.length * PREFERENCE_FAMILY_ROW_LIMIT
1959
+ };
1960
+ }
1961
+ var openReads = /* @__PURE__ */ new WeakMap();
1962
+ function joinSharedRead(dataSource, resource, userId, want) {
1963
+ let scopes = openReads.get(dataSource);
1964
+ if (!scopes) {
1965
+ scopes = /* @__PURE__ */ new Map();
1966
+ openReads.set(dataSource, scopes);
1967
+ }
1968
+ const scope = JSON.stringify([resource, userId]);
1969
+ const open = scopes.get(scope);
1970
+ if (open) {
1971
+ if ("key" in want) open.keys.add(want.key);
1972
+ else open.prefixes.add(want.prefix);
1973
+ return open;
1974
+ }
1975
+ const keys = new Set("key" in want ? [want.key] : []);
1976
+ const prefixes = new Set("prefix" in want ? [want.prefix] : []);
1977
+ const opened = scopes;
1978
+ const read = {
1979
+ keys,
1980
+ prefixes,
1981
+ answer: Promise.resolve().then(async () => {
1982
+ if (opened.get(scope) === read) opened.delete(scope);
1983
+ const query = preferenceQuery(userId, [...keys], [...prefixes]);
1984
+ const result = await dataSource.find(resource, query);
1985
+ const rows = result?.data ?? [];
1986
+ return {
1987
+ rows,
1988
+ saturated: prefixes.size > 0 && rows.length >= (query.$top ?? Infinity)
1989
+ };
1990
+ })
1991
+ };
1992
+ scopes.set(scope, read);
1993
+ return read;
1994
+ }
1995
+ function createPreferenceRowWriter(dataSource, resource, userId, key, onError) {
1073
1996
  let cachedRowId = null;
1074
- let saveChain = Promise.resolve();
1075
1997
  const findExisting = async () => {
1076
- const params = {
1077
- $filter: {
1078
- user_id: userId,
1079
- key
1080
- },
1081
- $top: 1
1082
- };
1083
- const result = await dataSource.find(resource, params);
1084
- const rows = result?.data ?? [];
1085
- const match = rows.find((r) => {
1086
- if (!r) return false;
1087
- const userMismatch = r.user_id !== void 0 && r.user_id !== userId;
1088
- const keyMismatch = r.key !== void 0 && r.key !== key;
1089
- return !userMismatch && !keyMismatch;
1090
- });
1091
- return match ?? null;
1092
- };
1093
- const decodeValue = (value) => {
1094
- if (Array.isArray(value)) return value;
1095
- if (typeof value === "string") {
1096
- try {
1097
- const parsed = JSON.parse(value);
1098
- return Array.isArray(parsed) ? parsed : [];
1099
- } catch {
1100
- return [];
1101
- }
1102
- }
1103
- return [];
1998
+ const result = await dataSource.find(resource, preferenceQuery(userId, [key]));
1999
+ return matchPreferenceRow(result?.data ?? [], userId, key);
1104
2000
  };
1105
- const upsert = async (items) => {
2001
+ const upsert = async (value) => {
1106
2002
  if (cachedRowId !== null) {
1107
2003
  try {
1108
- await dataSource.update(resource, cachedRowId, { value: items });
2004
+ await dataSource.update(resource, cachedRowId, { value });
1109
2005
  return;
1110
2006
  } catch (updateError) {
1111
2007
  cachedRowId = null;
@@ -1115,14 +2011,14 @@ function createObjectStackUserStateAdapter(options) {
1115
2011
  const existing = await findExisting();
1116
2012
  if (existing && existing.id !== void 0 && existing.id !== null) {
1117
2013
  cachedRowId = existing.id;
1118
- await dataSource.update(resource, existing.id, { value: items });
2014
+ await dataSource.update(resource, existing.id, { value });
1119
2015
  return;
1120
2016
  }
1121
2017
  try {
1122
2018
  const created = await dataSource.create(resource, {
1123
2019
  user_id: userId,
1124
2020
  key,
1125
- value: items
2021
+ value
1126
2022
  });
1127
2023
  const newId = created?.id;
1128
2024
  if (newId !== void 0 && newId !== null) cachedRowId = newId;
@@ -1130,21 +2026,74 @@ function createObjectStackUserStateAdapter(options) {
1130
2026
  const recovered = await findExisting();
1131
2027
  if (recovered && recovered.id !== void 0 && recovered.id !== null) {
1132
2028
  cachedRowId = recovered.id;
1133
- await dataSource.update(resource, recovered.id, { value: items });
2029
+ await dataSource.update(resource, recovered.id, { value });
1134
2030
  return;
1135
2031
  }
1136
2032
  throw createError;
1137
2033
  }
1138
2034
  };
2035
+ return {
2036
+ remember(id) {
2037
+ cachedRowId = id;
2038
+ },
2039
+ upsert,
2040
+ find: findExisting
2041
+ };
2042
+ }
2043
+ function matchPreferenceRow(rows, userId, key) {
2044
+ const match = rows.find((r) => {
2045
+ if (!r) return false;
2046
+ const userMismatch = r.user_id !== void 0 && r.user_id !== userId;
2047
+ const keyMismatch = r.key !== void 0 && r.key !== key;
2048
+ return !userMismatch && !keyMismatch;
2049
+ });
2050
+ return match ?? null;
2051
+ }
2052
+ function createObjectStackUserStateAdapter(options) {
2053
+ const {
2054
+ dataSource,
2055
+ userId,
2056
+ key,
2057
+ resource = DEFAULT_RESOURCE,
2058
+ onError = () => {
2059
+ }
2060
+ } = options;
2061
+ const writer = createPreferenceRowWriter(dataSource, resource, userId, key, onError);
2062
+ let saveChain = Promise.resolve();
2063
+ const loadExisting = async () => {
2064
+ const read = joinSharedRead(dataSource, resource, userId, { key });
2065
+ const { rows, saturated } = await read.answer;
2066
+ if (read.keys.size === 1 && read.prefixes.size === 0) {
2067
+ return matchPreferenceRow(rows, userId, key);
2068
+ }
2069
+ const own = rows.find(
2070
+ (r) => r && r.key === key && (r.user_id === void 0 || r.user_id === userId)
2071
+ );
2072
+ if (own) return own;
2073
+ if (saturated || rows.some((r) => r && r.key === void 0)) return writer.find();
2074
+ return null;
2075
+ };
2076
+ const decodeValue = (value) => {
2077
+ if (Array.isArray(value)) return value;
2078
+ if (typeof value === "string") {
2079
+ try {
2080
+ const parsed = JSON.parse(value);
2081
+ return Array.isArray(parsed) ? parsed : [];
2082
+ } catch {
2083
+ return [];
2084
+ }
2085
+ }
2086
+ return [];
2087
+ };
1139
2088
  return {
1140
2089
  async load() {
1141
2090
  try {
1142
- const row = await findExisting();
2091
+ const row = await loadExisting();
1143
2092
  if (!row) {
1144
- cachedRowId = null;
2093
+ writer.remember(null);
1145
2094
  return [];
1146
2095
  }
1147
- if (row.id !== void 0 && row.id !== null) cachedRowId = row.id;
2096
+ if (row.id !== void 0 && row.id !== null) writer.remember(row.id);
1148
2097
  return decodeValue(row.value);
1149
2098
  } catch (error) {
1150
2099
  onError("load", error);
@@ -1152,7 +2101,7 @@ function createObjectStackUserStateAdapter(options) {
1152
2101
  }
1153
2102
  },
1154
2103
  async save(items) {
1155
- const run = saveChain.then(() => upsert(items)).catch((error) => {
2104
+ const run = saveChain.then(() => writer.upsert(items)).catch((error) => {
1156
2105
  onError("save", error);
1157
2106
  });
1158
2107
  saveChain = run;
@@ -1160,6 +2109,137 @@ function createObjectStackUserStateAdapter(options) {
1160
2109
  }
1161
2110
  };
1162
2111
  }
2112
+ function createObjectStackUserPreferenceFamily(options) {
2113
+ const {
2114
+ dataSource,
2115
+ userId,
2116
+ prefix,
2117
+ resource = DEFAULT_RESOURCE,
2118
+ onError = () => {
2119
+ }
2120
+ } = options;
2121
+ const writers = /* @__PURE__ */ new Map();
2122
+ const chains = /* @__PURE__ */ new Map();
2123
+ const writerFor = (key) => {
2124
+ let writer = writers.get(key);
2125
+ if (!writer) {
2126
+ writer = createPreferenceRowWriter(dataSource, resource, userId, key, onError);
2127
+ writers.set(key, writer);
2128
+ }
2129
+ return writer;
2130
+ };
2131
+ const decodeValue = (value) => {
2132
+ let parsed = value;
2133
+ if (typeof value === "string") {
2134
+ try {
2135
+ parsed = JSON.parse(value);
2136
+ } catch {
2137
+ return void 0;
2138
+ }
2139
+ }
2140
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
2141
+ };
2142
+ return {
2143
+ prefix,
2144
+ async load() {
2145
+ try {
2146
+ const read = joinSharedRead(dataSource, resource, userId, { prefix });
2147
+ const { rows } = await read.answer;
2148
+ const out = {};
2149
+ for (const row of rows) {
2150
+ if (!row || typeof row.key !== "string" || !row.key.startsWith(prefix)) continue;
2151
+ if (row.user_id !== void 0 && row.user_id !== userId) continue;
2152
+ if (row.id !== void 0 && row.id !== null) writerFor(row.key).remember(row.id);
2153
+ const value = decodeValue(row.value);
2154
+ if (value !== void 0) out[row.key] = value;
2155
+ }
2156
+ return out;
2157
+ } catch (error) {
2158
+ onError("load", error);
2159
+ return {};
2160
+ }
2161
+ },
2162
+ async save(key, value) {
2163
+ if (!key.startsWith(prefix) || key.length === prefix.length) {
2164
+ onError("save", new Error(`key "${key}" is not in the family "${prefix}"`));
2165
+ return;
2166
+ }
2167
+ const run = (chains.get(key) ?? Promise.resolve()).then(() => writerFor(key).upsert(value)).catch((error) => {
2168
+ onError("save", error);
2169
+ });
2170
+ chains.set(key, run);
2171
+ return run;
2172
+ }
2173
+ };
2174
+ }
2175
+
2176
+ // src/draft-envelope.ts
2177
+ import { stripReadDecorations } from "@objectstack/spec/kernel";
2178
+ function extractDraftBody(resp) {
2179
+ if (!resp || typeof resp !== "object") return null;
2180
+ const env = resp;
2181
+ if (!("item" in env)) return null;
2182
+ const body = env.item;
2183
+ if (!body || typeof body !== "object") return null;
2184
+ if (Object.keys(body).length === 0) return null;
2185
+ return stripReadDecorations(body);
2186
+ }
2187
+ function readDraftChanges(resp) {
2188
+ if (!resp || typeof resp !== "object") return null;
2189
+ const changes = resp.draftChanges;
2190
+ return Array.isArray(changes) ? changes : null;
2191
+ }
2192
+ function isPathPrefix(prefix, path) {
2193
+ if (prefix.length > path.length) return false;
2194
+ for (let i = 0; i < prefix.length; i++) if (prefix[i] !== path[i]) return false;
2195
+ return true;
2196
+ }
2197
+ function governingDraftChange(changes, path) {
2198
+ let best = null;
2199
+ for (const change of changes ?? []) {
2200
+ if (!isPathPrefix(change.path, path)) continue;
2201
+ if (!best || change.path.length > best.path.length) best = change;
2202
+ }
2203
+ return best;
2204
+ }
2205
+ function draftChangesUnder(changes, path) {
2206
+ return (changes ?? []).filter((change) => change.path.length > path.length && isPathPrefix(path, change.path));
2207
+ }
2208
+
2209
+ // src/picklist-binding.ts
2210
+ function dropServedPicklistOptions(body) {
2211
+ if (!body || typeof body !== "object") return body;
2212
+ const fields = body.fields;
2213
+ if (Array.isArray(fields)) {
2214
+ let changed = false;
2215
+ const next = fields.map((def) => {
2216
+ const stripped = withoutServedOptions(def);
2217
+ if (stripped !== def) changed = true;
2218
+ return stripped;
2219
+ });
2220
+ return changed ? { ...body, fields: next } : body;
2221
+ }
2222
+ if (fields && typeof fields === "object") {
2223
+ let changed = false;
2224
+ const next = Object.fromEntries(
2225
+ Object.entries(fields).map(([name, def]) => {
2226
+ const stripped = withoutServedOptions(def);
2227
+ if (stripped !== def) changed = true;
2228
+ return [name, stripped];
2229
+ })
2230
+ );
2231
+ return changed ? { ...body, fields: next } : body;
2232
+ }
2233
+ return body;
2234
+ }
2235
+ function withoutServedOptions(def) {
2236
+ if (!def || typeof def !== "object" || Array.isArray(def)) return def;
2237
+ const record = def;
2238
+ if (record.picklist === void 0) return def;
2239
+ if (!Object.prototype.hasOwnProperty.call(record, "options")) return def;
2240
+ const { options: _served, ...authored } = record;
2241
+ return authored;
2242
+ }
1163
2243
 
1164
2244
  // src/index.ts
1165
2245
  var FILTER_OPERATOR_ALIASES = {
@@ -1218,37 +2298,136 @@ var FILTER_OPERATOR_ALIASES = {
1218
2298
  before: "<",
1219
2299
  after: ">"
1220
2300
  };
1221
- function normalizeFilterOperator2(op) {
1222
- if (typeof op !== "string") return null;
1223
- const lower = op.toLowerCase();
1224
- return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
2301
+ function toAstFilterOperator(op) {
2302
+ if (typeof op !== "string") return null;
2303
+ const lower = op.toLowerCase();
2304
+ return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
2305
+ }
2306
+ function describeFilterEntry(entry) {
2307
+ try {
2308
+ return JSON.stringify(entry, (_key, value) => typeof value === "bigint" ? `${value}n` : value) ?? String(entry);
2309
+ } catch {
2310
+ return String(entry);
2311
+ }
2312
+ }
2313
+ var MalformedFilterError = class extends Error {
2314
+ constructor(entry, index) {
2315
+ const shown = describeFilterEntry(entry);
2316
+ super(
2317
+ `Filter entry ${index} is not a usable filter rule (${shown}). Expected { field, operator, value } with a non-empty field.`
2318
+ );
2319
+ __publicField(this, "code", "INVALID_FILTER");
2320
+ __publicField(this, "httpStatus", 400);
2321
+ __publicField(this, "entry");
2322
+ __publicField(this, "index");
2323
+ this.name = "MalformedFilterError";
2324
+ this.entry = entry;
2325
+ this.index = index;
2326
+ }
2327
+ };
2328
+ function isMalformedFilterError(error) {
2329
+ if (!error || typeof error !== "object") return false;
2330
+ const e = error;
2331
+ return e.code === "INVALID_FILTER" || e.name === "MalformedFilterError";
2332
+ }
2333
+ var UnloweredAggregateWhereError = class extends Error {
2334
+ constructor(where, resource) {
2335
+ const shown = JSON.stringify(where) ?? String(where);
2336
+ super(
2337
+ `aggregate('${resource}'): the spec-shape branch received a \`where\` array that is not a filter \u2014 ${shown}. This branch posts \`where\` to POST /data/:object/query verbatim, so it must ALREADY be lowered: either a FilterCondition object (\`QuerySchema.where\`, @objectstack/spec data/query.zod.ts), or a FilterArray the ingress can lower \u2014 a comparison tuple (['stage','=','won']), a logical node (['and',[..],[..]]), or an array of nodes (data/filter.zod.ts, gate \`isFilterAST\`). A ViewFilterRule[] ([{ field, operator, value }, ...]) is authoring sugar, not a filter: lower it in the producer that built these aggregate params, not here. Nothing was sent to the server, so no unfiltered numbers came back.`
2338
+ );
2339
+ __publicField(this, "code", "INVALID_FILTER");
2340
+ __publicField(this, "httpStatus", 400);
2341
+ /** The value as received, so a caller can log what its producer actually built. */
2342
+ __publicField(this, "where");
2343
+ /** The object `aggregate()` was called for. */
2344
+ __publicField(this, "resource");
2345
+ this.name = "UnloweredAggregateWhereError";
2346
+ this.where = where;
2347
+ this.resource = resource;
2348
+ }
2349
+ };
2350
+ function assertSpecShapeWhereIsFilterAst(where, resource) {
2351
+ if (!Array.isArray(where)) return;
2352
+ if (where.length === 0) return;
2353
+ if (isFilterAST(where)) return;
2354
+ throw new UnloweredAggregateWhereError(where, resource);
2355
+ }
2356
+ var ANALYTICS_ONLY_AGGREGATE_KEYS = ["filter", "field", "function"];
2357
+ var ANALYTICS_ONLY_KEY_ADVICE = {
2358
+ filter: "`filter` is the ANALYTICS branch's filter and is lowered there through `parseFilterAST`; this branch's filter is `where` (`QuerySchema.where`, @objectstack/spec data/query.zod.ts) and is posted VERBATIM \u2014 rename it to `where` and lower it in the producer.",
2359
+ field: "`field` is half of the analytics branch's `${field}_${function}` measure; this branch's measure is `aggregations`, an AggregationNode[] such as [{ function: 'sum', field: 'amount', alias: 'amount_sum' }].",
2360
+ function: "`function` is the other half of that measure pair and belongs inside an `aggregations` node, not beside `groupBy`."
2361
+ };
2362
+ function specShapeSelectorReasons(params) {
2363
+ const reasons = [];
2364
+ if (Array.isArray(params?.groupBy)) reasons.push("`groupBy` is an array");
2365
+ if (Array.isArray(params?.aggregations)) reasons.push("`aggregations` is an array");
2366
+ if (params?.where !== void 0) reasons.push("a `where` key is present");
2367
+ return reasons;
2368
+ }
2369
+ var AnalyticsKeysOnSpecShapeError = class extends Error {
2370
+ constructor(keys, params, resource) {
2371
+ const received = {};
2372
+ for (const key of keys) received[key] = params?.[key];
2373
+ const named = keys.map((k) => `\`${k}\``).join(", ");
2374
+ const shown = keys.map((k) => `${k}=${JSON.stringify(params?.[k]) ?? String(params?.[k])}`).join(", ");
2375
+ const why = specShapeSelectorReasons(params).join(" and ");
2376
+ const noMeasure = !Array.isArray(params?.aggregations) ? " Because no `aggregations` was supplied either, the query this would have built is a grouping with NO MEASURE \u2014 the chart renders and its numbers mean nothing." : "";
2377
+ super(
2378
+ `aggregate('${resource}'): the spec-shape branch received the analytics branch's ${named} \u2014 ${shown} \u2014 and reads none of them. This call took the spec-shape branch because ${why}; the branch posts only \`groupBy\`, \`aggregations\`, \`where\` and \`limit\` to POST /data/:object/query, so those keys would have been dropped in silence.${noMeasure} ` + keys.map((k) => ANALYTICS_ONLY_KEY_ADVICE[k]).join(" ") + " The two shapes are alternatives, not a mixture: the analytics branch (`{ field, function, groupBy, filter }`, which lowers its filter for you) is reached only when NONE of an array `groupBy`, an array `aggregations` or a `where` key is present. Nothing was sent to the server, so no unfiltered numbers came back."
2379
+ );
2380
+ __publicField(this, "code", "INVALID_FILTER");
2381
+ __publicField(this, "httpStatus", 400);
2382
+ /** The offending keys, in the order {@link ANALYTICS_ONLY_AGGREGATE_KEYS} lists them. */
2383
+ __publicField(this, "keys");
2384
+ /** What each offending key carried, so a producer is identifiable from a log. */
2385
+ __publicField(this, "received");
2386
+ /** The object `aggregate()` was called for. */
2387
+ __publicField(this, "resource");
2388
+ this.name = "AnalyticsKeysOnSpecShapeError";
2389
+ this.keys = keys;
2390
+ this.received = received;
2391
+ this.resource = resource;
2392
+ }
2393
+ };
2394
+ function assertNoAnalyticsKeysOnSpecShape(params, resource) {
2395
+ const present2 = ANALYTICS_ONLY_AGGREGATE_KEYS.filter((key) => params?.[key] != null);
2396
+ if (present2.length === 0) return;
2397
+ throw new AnalyticsKeysOnSpecShapeError(present2, params, resource);
1225
2398
  }
1226
- var MalformedFilterError = class extends Error {
1227
- constructor(entry, index) {
1228
- const shown = JSON.stringify(entry) ?? String(entry);
2399
+ var UnlowerableAnalyticsFilterError = class extends Error {
2400
+ constructor(filter, resource, detail) {
2401
+ const shown = JSON.stringify(filter) ?? String(filter);
1229
2402
  super(
1230
- `Filter entry ${index} is not a usable filter rule (${shown}). Expected { field, operator, value } with a non-empty field.`
2403
+ `aggregate('${resource}'): the analytics branch received a \`filter\` array that \`parseFilterAST\` cannot lower into a FilterCondition \u2014 ${shown}.` + (detail ? ` (${detail})` : "") + " `where` on the analytics wire is a FilterCondition (`AnalyticsQuerySchema`, @objectstack/spec data/analytics.zod.ts), and a FilterArray is input-only sugar lowered into one at the single sink `parseFilterAST` (data/filter.zod.ts, objectstack#5158 ruling C). Lowerable shapes are a comparison tuple (['stage','=','won']), a logical node (['and',[..],[..]]), an array of such nodes, and a ViewFilterRule[] this adapter translates first. An infix join ([condA, 'or', condB]) is none of them. Nothing was sent to the server, so no unfiltered numbers came back."
1231
2404
  );
1232
2405
  __publicField(this, "code", "INVALID_FILTER");
1233
2406
  __publicField(this, "httpStatus", 400);
1234
- __publicField(this, "entry");
1235
- __publicField(this, "index");
1236
- this.name = "MalformedFilterError";
1237
- this.entry = entry;
1238
- this.index = index;
2407
+ /** The filter as received, so a caller can log what its producer actually built. */
2408
+ __publicField(this, "filter");
2409
+ /** The object `aggregate()` was called for. */
2410
+ __publicField(this, "resource");
2411
+ this.name = "UnlowerableAnalyticsFilterError";
2412
+ this.filter = filter;
2413
+ this.resource = resource;
1239
2414
  }
1240
2415
  };
1241
- function isMalformedFilterError(error) {
1242
- if (!error || typeof error !== "object") return false;
1243
- const e = error;
1244
- return e.code === "INVALID_FILTER" || e.name === "MalformedFilterError";
2416
+ function refuseTextComparandEntry(entry, index, field, arrived) {
2417
+ const tail = `This is a { field, operator, value } rule entry, so it is refused as the filter is translated rather than sent: the spelling @objectstack/spec's FILTER_TEXT_CASES uses for this operator is the $-dialect '$icontains', which this form spells '${arrived}'. Remove the entry, or give it a non-empty string value (objectui#9048; the same refusal convertFiltersToAST and ValueDataSource already share).`;
2418
+ const error = new MalformedFilterError(entry, index);
2419
+ error.message = `Filter entry ${index} is refused. The ${textComparandRefusalReason2(field, arrived, entry.value)}. ${tail}`;
2420
+ throw error;
1245
2421
  }
1246
- function objectFilterEntryToAST(entry) {
2422
+ function objectFilterEntryToAST(entry, index) {
1247
2423
  if (!entry || typeof entry !== "object") return null;
1248
2424
  const field = entry.field;
1249
2425
  const rawOp = entry.operator ?? entry.op ?? "=";
1250
- const op = normalizeFilterOperator2(rawOp);
2426
+ const op = toAstFilterOperator(rawOp);
1251
2427
  if (!field || !op) return null;
2428
+ if (op === "icontains" && isRefusedTextComparand2(entry.value)) {
2429
+ refuseTextComparandEntry(entry, index, String(field), rawOp);
2430
+ }
1252
2431
  return [String(field), op, entry.value];
1253
2432
  }
1254
2433
  function isObjectFilterEntryForm(filter) {
@@ -1257,8 +2436,8 @@ function isObjectFilterEntryForm(filter) {
1257
2436
  }
1258
2437
  function objectFilterEntriesToAST(entries) {
1259
2438
  const nodes = entries.map((entry, i) => {
1260
- if (Array.isArray(entry)) return translateFilterChild(entry);
1261
- const tuple = objectFilterEntryToAST(entry);
2439
+ if (Array.isArray(entry)) return translateFilterChild(entry, i);
2440
+ const tuple = objectFilterEntryToAST(entry, i);
1262
2441
  if (!tuple) throw new MalformedFilterError(entry, i);
1263
2442
  return tuple;
1264
2443
  });
@@ -1269,15 +2448,17 @@ function translateFilterArray(filter) {
1269
2448
  if (isObjectFilterEntryForm(filter)) return objectFilterEntriesToAST(filter);
1270
2449
  const head = filter[0];
1271
2450
  if (typeof head === "string" && LOGICAL_AST_HEADS.has(head.toLowerCase())) {
1272
- return [head, ...filter.slice(1).map(translateFilterChild)];
2451
+ return [head, ...filter.slice(1).map((child, i) => translateFilterChild(child, i + 1))];
2452
+ }
2453
+ if (filter.every((child) => Array.isArray(child))) {
2454
+ return filter.map((child, i) => translateFilterChild(child, i));
1273
2455
  }
1274
- if (filter.every((child) => Array.isArray(child))) return filter.map(translateFilterChild);
1275
2456
  return filter;
1276
2457
  }
1277
- function translateFilterChild(child) {
2458
+ function translateFilterChild(child, index) {
1278
2459
  if (Array.isArray(child)) return child.length > 0 ? translateFilterArray(child) : child;
1279
2460
  if (child && typeof child === "object" && child.field !== void 0) {
1280
- const tuple = objectFilterEntryToAST(child);
2461
+ const tuple = objectFilterEntryToAST(child, index);
1281
2462
  if (tuple) return tuple;
1282
2463
  }
1283
2464
  return child;
@@ -1294,6 +2475,30 @@ function translateFilterToAST(filter) {
1294
2475
  }
1295
2476
  return void 0;
1296
2477
  }
2478
+ function lowerAnalyticsFilterForWire(filter, resource) {
2479
+ if (!Array.isArray(filter)) return filter;
2480
+ if (filter.length === 0) return void 0;
2481
+ const ast = translateFilterArray(filter);
2482
+ if (!isFilterAST(ast)) throw new UnlowerableAnalyticsFilterError(filter, resource);
2483
+ let lowered;
2484
+ try {
2485
+ lowered = parseFilterAST(ast);
2486
+ } catch (e) {
2487
+ throw new UnlowerableAnalyticsFilterError(
2488
+ filter,
2489
+ resource,
2490
+ e instanceof Error ? e.message : String(e)
2491
+ );
2492
+ }
2493
+ if (lowered === void 0) {
2494
+ throw new UnlowerableAnalyticsFilterError(
2495
+ filter,
2496
+ resource,
2497
+ "`parseFilterAST` answered `undefined` for a non-empty filter"
2498
+ );
2499
+ }
2500
+ return lowered;
2501
+ }
1297
2502
  function serializeOrderBy(orderby) {
1298
2503
  if (orderby === void 0 || orderby === null) return void 0;
1299
2504
  if (typeof orderby === "string") {
@@ -1311,6 +2516,7 @@ function serializeOrderBy(orderby) {
1311
2516
  }
1312
2517
  return void 0;
1313
2518
  }
2519
+ var VIEW_LIST_KEY = "list:view";
1314
2520
  var discoveryCache = /* @__PURE__ */ new Map();
1315
2521
  async function getSharedDiscovery(baseUrl, fetcher) {
1316
2522
  const key = baseUrl || "<default>";
@@ -1356,6 +2562,14 @@ var APP_PERMISSION_DENIED_CODE = "PERMISSION_DENIED";
1356
2562
  function isAppPermissionDeniedError(error) {
1357
2563
  return errorCodeIs(error, APP_PERMISSION_DENIED_CODE);
1358
2564
  }
2565
+ var META_ITEM_ABSENT_CODE = "RESOURCE_NOT_FOUND";
2566
+ function isMetaItemAbsentError(error) {
2567
+ return errorCodeIs(error, META_ITEM_ABSENT_CODE);
2568
+ }
2569
+ function metaEnvelopeCarriesItem(envelope) {
2570
+ if (envelope == null || typeof envelope !== "object") return false;
2571
+ return envelope.item != null;
2572
+ }
1359
2573
  var AnalyticsNotInstalledError = class extends Error {
1360
2574
  constructor(surface, detail, serverCode) {
1361
2575
  super(
@@ -1424,6 +2638,25 @@ var AnalyticsUnauthenticatedError = class extends Error {
1424
2638
  this.serverMessage = opts.serverMessage;
1425
2639
  }
1426
2640
  };
2641
+ var AnalyticsForbiddenError = class extends Error {
2642
+ constructor(opts = {}) {
2643
+ super(
2644
+ `Analytics query refused: this user may not read the data behind ${opts.datasetName ? `dataset "${opts.datasetName}"` : "this dataset"}.` + (opts.serverMessage ? ` (server said: ${opts.serverMessage})` : "")
2645
+ );
2646
+ __publicField(this, "code", "PERMISSION_DENIED");
2647
+ __publicField(this, "httpStatus", 403);
2648
+ /** The server's own ADR-0112 code — the field this branch was chosen BY. */
2649
+ __publicField(this, "serverCode");
2650
+ /** The server's own message, verbatim (diagnostics only). */
2651
+ __publicField(this, "serverMessage");
2652
+ /** The dataset the refused query asked for, when it named one. */
2653
+ __publicField(this, "datasetName");
2654
+ this.name = "AnalyticsForbiddenError";
2655
+ this.serverCode = opts.serverCode;
2656
+ this.serverMessage = opts.serverMessage;
2657
+ this.datasetName = opts.datasetName;
2658
+ }
2659
+ };
1427
2660
  function readAnalyticsErrorEnvelope(body) {
1428
2661
  if (!body || typeof body !== "object") return {};
1429
2662
  const flat = body;
@@ -1446,6 +2679,30 @@ var AnalyticsQueryRejectedError = class extends Error {
1446
2679
  this.serverCode = serverCode;
1447
2680
  }
1448
2681
  };
2682
+ var AnalyticsResultShapeError = class extends Error {
2683
+ constructor(resource, result) {
2684
+ const value = result;
2685
+ const envelope = Array.isArray(value?.data?.rows);
2686
+ const received = result === null ? "null" : Array.isArray(result) ? "an array" : typeof result !== "object" ? typeof result : `an object with keys [${Object.keys(result).join(", ")}]`;
2687
+ super(
2688
+ envelope ? `aggregate('${resource}'): client.analytics.query resolved to the \`{ data: { rows } }\` envelope, not to an AnalyticsResult. @objectstack/client resolves this method to the payload itself since 17.3.0 (objectstack#13079), and this adapter reads \`rows\` only: an older client is installed beside it, or something between the client and POST /analytics/query wraps the payload a second time. Install @objectstack/client 17.3.0 or later.` : `aggregate('${resource}'): client.analytics.query resolved to ${received}, which has no \`rows\` array, so it is not an AnalyticsResult. This adapter reads \`rows\` only (objectui#7028).`
2689
+ );
2690
+ __publicField(this, "code", "ANALYTICS_RESULT_SHAPE_INVALID");
2691
+ /** The object `aggregate()` was called for. */
2692
+ __publicField(this, "resource");
2693
+ /**
2694
+ * True when the value is the envelope the client stopped handing back at
2695
+ * 17.3.0 — `{ success, data: { rows } }`, or its `{ data: { rows } }` core.
2696
+ */
2697
+ __publicField(this, "envelope");
2698
+ /** What the value was, so the producer is identifiable from a log. */
2699
+ __publicField(this, "received");
2700
+ this.name = "AnalyticsResultShapeError";
2701
+ this.resource = resource;
2702
+ this.envelope = envelope;
2703
+ this.received = received;
2704
+ }
2705
+ };
1449
2706
  function classifyAnalyticsFailure(error) {
1450
2707
  const err = error ?? {};
1451
2708
  const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
@@ -1457,11 +2714,11 @@ function classifyAnalyticsFailure(error) {
1457
2714
  if (errorCodeIs({ code }, "VALIDATION_FAILED")) return { kind: "rejected", code, message };
1458
2715
  if (errorCodeIs({ code }, "UNAUTHENTICATED")) return { kind: "unauthenticated", code, message };
1459
2716
  if (errorCodeIs({ code }, "CUBE_NOT_FOUND")) return { kind: "cube-not-found", code, message };
2717
+ if (status === 400) return { kind: "rejected", code, message };
1460
2718
  if (code === void 0) {
1461
2719
  if (status !== void 0 && ANALYTICS_ABSENT_STATUSES.includes(status)) {
1462
2720
  return { kind: "not-installed", code, message };
1463
2721
  }
1464
- if (status === 400) return { kind: "rejected", code, message };
1465
2722
  if (status === 401) return { kind: "unauthenticated", code, message };
1466
2723
  }
1467
2724
  return { kind: "unknown", code, message };
@@ -1473,9 +2730,22 @@ var ConcurrentUpdateError = class extends Error {
1473
2730
  __publicField(this, "httpStatus", 409);
1474
2731
  __publicField(this, "currentVersion");
1475
2732
  __publicField(this, "currentRecord");
2733
+ /**
2734
+ * The refusal text the PRODUCER marked as addressed to the end user
2735
+ * (`ApiErrorSchema.userMessage`, objectstack `79c46da90`), or `null` when the
2736
+ * refusal carried no marking.
2737
+ *
2738
+ * Declared on the class because this error has no `details` bag: the shared
2739
+ * reader (`declaredUserMessage` in `@object-ui/react`) looks at the
2740
+ * top-level field first, so parking it here is what makes the marking
2741
+ * readable at the surface. The contract is status-agnostic — a 409 carries
2742
+ * it exactly like a 400 or a 403 does.
2743
+ */
2744
+ __publicField(this, "userMessage");
1476
2745
  this.name = "ConcurrentUpdateError";
1477
2746
  this.currentVersion = opts.currentVersion;
1478
2747
  this.currentRecord = opts.currentRecord;
2748
+ this.userMessage = opts.userMessage ?? null;
1479
2749
  }
1480
2750
  };
1481
2751
  function isConcurrentUpdateError(error) {
@@ -1483,10 +2753,18 @@ function isConcurrentUpdateError(error) {
1483
2753
  const e = error;
1484
2754
  return e.code === "CONCURRENT_UPDATE" || e.name === "ConcurrentUpdateError";
1485
2755
  }
2756
+ function liftUserMessage(e, details) {
2757
+ if (typeof e.userMessage === "string" && e.userMessage.trim()) return e.userMessage;
2758
+ if (typeof details.userMessage === "string" && details.userMessage.trim()) {
2759
+ return details.userMessage;
2760
+ }
2761
+ return null;
2762
+ }
1486
2763
  function normaliseClientError(error) {
1487
2764
  if (!error || typeof error !== "object") return error;
1488
2765
  const e = error;
1489
2766
  const details = e.details ?? {};
2767
+ const userMessage = liftUserMessage(e, details);
1490
2768
  if (e.code === "VALIDATION_FAILED" || e.name === "ValidationError") {
1491
2769
  const rawFields = Array.isArray(details.fields) ? details.fields : Array.isArray(e.fields) ? e.fields : [];
1492
2770
  const validationErrors = rawFields.map((f) => {
@@ -1500,15 +2778,21 @@ function normaliseClientError(error) {
1500
2778
  typeof e.message === "string" ? e.message : "Validation failed",
1501
2779
  validationErrors[0]?.field,
1502
2780
  validationErrors,
1503
- { fields: rawFields }
2781
+ // The marking rides the details bag exactly the way `fields` already
2782
+ // does, landing on `err.details.userMessage` — the second of the two
2783
+ // places the shared reader looks. Purely additive: `field`,
2784
+ // `validationErrors` and `fields` are untouched, so the per-field
2785
+ // marking a form already draws keeps working. Omitted entirely when
2786
+ // unmarked, so an unmarked refusal carries no empty key.
2787
+ userMessage === null ? { fields: rawFields } : { fields: rawFields, userMessage }
1504
2788
  );
1505
2789
  }
1506
- if (e.code !== "CONCURRENT_UPDATE" && e.httpStatus !== 409) return error;
1507
2790
  if (e.code !== "CONCURRENT_UPDATE") return error;
1508
2791
  return new ConcurrentUpdateError({
1509
2792
  currentVersion: typeof details.currentVersion === "string" ? details.currentVersion : null,
1510
2793
  currentRecord: details.currentRecord ?? null,
1511
- message: typeof e.message === "string" ? e.message : void 0
2794
+ message: typeof e.message === "string" ? e.message : void 0,
2795
+ userMessage
1512
2796
  });
1513
2797
  }
1514
2798
  function formatHttpFailureMessage(message, meta) {
@@ -1563,6 +2847,80 @@ function createQuietHttpLogger() {
1563
2847
  };
1564
2848
  return logger;
1565
2849
  }
2850
+ var RECOGNIZED_DROP_REASONS = new Set(
2851
+ DroppedFieldsEventSchema.shape.reason.options
2852
+ );
2853
+ var UNRECOGNIZED_DROP_REASON = "objectui:unrecognized-drop-reason";
2854
+ function isRecognizedDropReason(reason) {
2855
+ return RECOGNIZED_DROP_REASONS.has(reason);
2856
+ }
2857
+ function isWireDroppedFieldsEntry(e) {
2858
+ return !!e && typeof e === "object" && Array.isArray(e.fields) && e.fields.some((f) => typeof f === "string");
2859
+ }
2860
+ function asDroppedFieldsNotice(entry, fallbackObject) {
2861
+ const fields = entry.fields.filter((f) => typeof f === "string");
2862
+ const object = typeof entry.object === "string" ? entry.object : fallbackObject;
2863
+ if (isRecognizedDropReason(entry.reason)) {
2864
+ return { ...entry, object, fields, reason: entry.reason };
2865
+ }
2866
+ return {
2867
+ ...entry,
2868
+ object,
2869
+ fields,
2870
+ reason: UNRECOGNIZED_DROP_REASON,
2871
+ unrecognizedReason: entry.reason
2872
+ };
2873
+ }
2874
+ var UNATTRIBUTED_STRIP_OBJECT = "";
2875
+ var META_ROUTE_ABSENT_CODES = ["ROUTE_NOT_FOUND", "NOT_IMPLEMENTED"];
2876
+ var IMPORT_MAPPINGS_UNKNOWN_KIND_CODE = "INVALID_REQUEST";
2877
+ var IMPORT_MAPPINGS_UNKNOWN_KIND_STATUS = 400;
2878
+ var META_ABSENT_STATUSES = [404, 501];
2879
+ var META_REFUSAL_CODES = ["UNAUTHENTICATED", "PERMISSION_DENIED"];
2880
+ var META_REFUSAL_STATUSES = [401, 403, 405];
2881
+ function classifyImportMappingsFailure(error) {
2882
+ const err = error ?? {};
2883
+ const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
2884
+ const message = typeof err.message === "string" ? err.message : void 0;
2885
+ const status = typeof err.httpStatus === "number" ? err.httpStatus : typeof err.status === "number" ? err.status : typeof err.statusCode === "number" ? err.statusCode : void 0;
2886
+ const found = { code, status, message };
2887
+ if (errorCodeIsAnyOf({ code }, META_ROUTE_ABSENT_CODES)) {
2888
+ return { kind: "not-served", ...found };
2889
+ }
2890
+ if (errorCodeIs({ code }, IMPORT_MAPPINGS_UNKNOWN_KIND_CODE) && status === IMPORT_MAPPINGS_UNKNOWN_KIND_STATUS) {
2891
+ return { kind: "not-served", ...found };
2892
+ }
2893
+ if (errorCodeIsAnyOf({ code }, META_REFUSAL_CODES)) {
2894
+ return { kind: "refused", ...found };
2895
+ }
2896
+ if (status !== void 0 && META_REFUSAL_STATUSES.includes(status)) {
2897
+ return { kind: "refused", ...found };
2898
+ }
2899
+ if (code === void 0 && status !== void 0 && META_ABSENT_STATUSES.includes(status)) {
2900
+ return { kind: "not-served", ...found };
2901
+ }
2902
+ return { kind: "unreadable", ...found };
2903
+ }
2904
+ function classifyViewsFailure(error) {
2905
+ const err = error ?? {};
2906
+ const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
2907
+ const message = typeof err.message === "string" ? err.message : void 0;
2908
+ const status = typeof err.httpStatus === "number" ? err.httpStatus : typeof err.status === "number" ? err.status : typeof err.statusCode === "number" ? err.statusCode : void 0;
2909
+ const found = { code, status, message };
2910
+ if (errorCodeIsAnyOf({ code }, META_ROUTE_ABSENT_CODES)) {
2911
+ return { kind: "not-served", ...found };
2912
+ }
2913
+ if (errorCodeIsAnyOf({ code }, META_REFUSAL_CODES)) {
2914
+ return { kind: "refused", ...found };
2915
+ }
2916
+ if (status !== void 0 && META_REFUSAL_STATUSES.includes(status)) {
2917
+ return { kind: "refused", ...found };
2918
+ }
2919
+ if (code === void 0 && status !== void 0 && META_ABSENT_STATUSES.includes(status)) {
2920
+ return { kind: "not-served", ...found };
2921
+ }
2922
+ return { kind: "unreadable", ...found };
2923
+ }
1566
2924
  function stableStringify(value) {
1567
2925
  if (value === null || typeof value !== "object") return JSON.stringify(value);
1568
2926
  if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
@@ -1577,6 +2935,16 @@ function sameWireValue(a, b) {
1577
2935
  if (aEmpty || bEmpty) return aEmpty && bEmpty;
1578
2936
  return stableStringify(a) === stableStringify(b);
1579
2937
  }
2938
+ function readWriteWarnings(result) {
2939
+ const raw = result?.warnings;
2940
+ if (!Array.isArray(raw) || raw.length === 0) return [];
2941
+ const out = [];
2942
+ for (const entry of raw) {
2943
+ const parsed = ValidateDataIssueSchema.safeParse(entry);
2944
+ if (parsed.success) out.push(parsed.data);
2945
+ }
2946
+ return out;
2947
+ }
1580
2948
  function withoutNoOpDrops(droppedFields, sent, stored) {
1581
2949
  if (!sent || !stored || typeof sent !== "object" || typeof stored !== "object") {
1582
2950
  return droppedFields;
@@ -1595,20 +2963,14 @@ function withoutNoOpDrops(droppedFields, sent, stored) {
1595
2963
  }
1596
2964
  return out;
1597
2965
  }
2966
+ var VIEW_ITEM_ROUND_TRIP_KEYS = Object.keys(VIEW_CONSOLE_ROUND_TRIP_KEYS).filter((key) => VIEW_CONSOLE_ROUND_TRIP_KEYS[key].includes("viewItem"));
1598
2967
  function viewItemObjectName(item) {
1599
2968
  const spec = item?.list ?? item;
1600
- return spec?.data?.object ?? spec?.object ?? spec?.objectName;
2969
+ return spec?.data?.object ?? spec?.object;
1601
2970
  }
1602
2971
  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
2972
  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);
2973
+ return item?.[VIEW_OVERLAY_MARKER] === true || spec?.[VIEW_OVERLAY_MARKER] === true;
1612
2974
  }
1613
2975
  var VIEW_OVERLAY_OWNED_KEYS = Object.freeze([
1614
2976
  "rowHeight",
@@ -1640,8 +3002,9 @@ function narrowPersonalizationOverlay(row) {
1640
3002
  function unwrapViewDraft(resp) {
1641
3003
  if (!resp || typeof resp !== "object") return null;
1642
3004
  const env = resp;
1643
- const body = "item" in env ? env.item : env;
1644
- if (!body || typeof body !== "object") return null;
3005
+ const raw = "item" in env ? env.item : env;
3006
+ if (!raw || typeof raw !== "object") return null;
3007
+ const body = stripReadDecorations2(raw);
1645
3008
  const spec = body.list ?? body;
1646
3009
  if (!spec || typeof spec !== "object") return null;
1647
3010
  return Object.keys(spec).length > 0 ? spec : null;
@@ -1662,6 +3025,10 @@ function mergeViewPatch(current, partial, viewName, objectName) {
1662
3025
  object: current?.object || current?.data?.object || objectName
1663
3026
  };
1664
3027
  }
3028
+ function isCallerAbort(error, init) {
3029
+ if (init?.signal?.aborted) return true;
3030
+ return error?.name === "AbortError";
3031
+ }
1665
3032
  var ObjectStackAdapter = class {
1666
3033
  constructor(config) {
1667
3034
  __publicField(this, "client");
@@ -1685,6 +3052,13 @@ var ObjectStackAdapter = class {
1685
3052
  // multiple sibling components requesting the same dataset on first paint)
1686
3053
  // into a single network round trip.
1687
3054
  __publicField(this, "inflightFinds", /* @__PURE__ */ new Map());
3055
+ // In-flight findOne() reads keyed by resource + record id + serialized
3056
+ // params, shared the same way (objectui#11699). Opening one record page
3057
+ // asked for the same record twice at once: `record:details`' DetailView
3058
+ // re-runs its load effect while its first read is still on the wire. A
3059
+ // write to a resource drops that resource's entries (see `emitMutation`),
3060
+ // so a read asked after a save is never answered by one sent before it.
3061
+ __publicField(this, "inflightFindOnes", /* @__PURE__ */ new Map());
1688
3062
  // Resources that have responded 404 at least once (collection not installed
1689
3063
  // on this backend). Subsequent find() calls short-circuit to an empty result
1690
3064
  // so optional collections like sys_presence don't hammer the server with
@@ -1697,10 +3071,10 @@ var ObjectStackAdapter = class {
1697
3071
  // one adapter that has to cope with a backend lacking server atomicity (#2679).
1698
3072
  __publicField(this, "batchUnsupported", false);
1699
3073
  // The server's declared cross-object atomic-batch capability, read from
1700
- // discovery at connect() (framework #3298 / objectui #2693). `true` → the
3074
+ // discovery at connect() (objectstack-ai/objectstack#3298 / objectui #2693). `true` → the
1701
3075
  // backend GUARANTEES an atomic `/batch`, so batchTransaction trusts it and
1702
3076
  // never degrades to the non-atomic emulation (any failure surfaces as a real
1703
- // error). `false` or `undefined` (capability absent → backend predates #3298)
3077
+ // error). `false` or `undefined` (capability absent → backend predates objectstack-ai/objectstack#3298)
1704
3078
  // → keep the legacy runtime-probe + emulation fallback so a save is still
1705
3079
  // possible; dropping it there would turn "saves, less safe" into "no save
1706
3080
  // path" on older backends (#2679 compatibility constraint).
@@ -1711,7 +3085,7 @@ var ObjectStackAdapter = class {
1711
3085
  // inline-edit "Save All" writes without a manual reload.
1712
3086
  __publicField(this, "mutationListeners", /* @__PURE__ */ new Set());
1713
3087
  // Subscribers registered via onWriteWarning(). Emitted after a create/update
1714
- // whose response carried `droppedFields` (framework #3431/#3455) so the app
3088
+ // whose response carried `droppedFields` (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455) so the app
1715
3089
  // shell can surface a toast instead of the strip passing silently.
1716
3090
  __publicField(this, "writeWarningListeners", /* @__PURE__ */ new Set());
1717
3091
  // Subscribers registered via onSaveAdvisory(). Emitted after a metadata save
@@ -1720,62 +3094,85 @@ var ObjectStackAdapter = class {
1720
3094
  // above in every respect except which door produced the event: that one is
1721
3095
  // record CRUD, this one is the metadata save door.
1722
3096
  __publicField(this, "saveAdvisoryListeners", /* @__PURE__ */ new Set());
1723
- this.client = new ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
3097
+ // Subscribers registered via onMetadataReadWarning(). Emitted when a metadata
3098
+ // READ failed in a way that is NOT the supported "this deployment does not
3099
+ // serve that kind" shape, and was degraded to an empty result anyway
3100
+ // (objectui#7741). Third sibling of the two sets above: same seam shape, and
3101
+ // the opposite direction — those two describe a write that succeeded, this
3102
+ // one a read that did not happen.
3103
+ __publicField(this, "metadataReadWarningListeners", /* @__PURE__ */ new Set());
3104
+ const transport = config.fetch || globalThis.fetch.bind(globalThis);
3105
+ this.fetchImpl = (input, init) => this.observeServerAnswer(() => transport(input, init), init);
3106
+ this.client = new ObjectStackClient({
3107
+ ...config,
3108
+ fetch: this.fetchImpl,
3109
+ logger: createQuietHttpLogger()
3110
+ });
1724
3111
  this.installSaveAdvisoryInterceptor();
1725
3112
  this.metadataCache = new MetadataCache(config.cache);
3113
+ this.installViewListShare();
1726
3114
  this.autoReconnect = config.autoReconnect ?? true;
1727
3115
  this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3;
1728
3116
  this.reconnectDelay = config.reconnectDelay ?? 1e3;
1729
3117
  this.baseUrl = config.baseUrl;
1730
3118
  this.token = config.token;
1731
- this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
1732
3119
  }
1733
3120
  /**
1734
3121
  * Ensure the client is connected to the server.
1735
3122
  * Call this before making requests or it will auto-connect on first request.
3123
+ *
3124
+ * With `autoReconnect`, a failed discovery read is retried up to
3125
+ * `maxReconnectAttempts` times with exponential backoff. The promise resolves
3126
+ * once discovery answers and rejects with the last `ConnectionError` once the
3127
+ * retries are spent (objectui#12318).
1736
3128
  */
1737
3129
  async connect() {
1738
3130
  if (this.connected) return;
1739
3131
  if (this.connectPromise) return this.connectPromise;
1740
3132
  this.setConnectionState("connecting");
1741
- this.connectPromise = (async () => {
1742
- try {
1743
- const baseUrl = this.baseUrl || "";
1744
- const discoveryUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/api/v1/discovery` : "/api/v1/discovery";
1745
- const data = await getSharedDiscovery(baseUrl, async () => {
1746
- const res = await this.fetchImpl(discoveryUrl, {
1747
- method: "GET",
1748
- headers: this.token ? { Authorization: `Bearer ${this.token}` } : void 0
1749
- });
1750
- if (!res.ok) {
1751
- throw new Error(`discovery ${res.status} ${res.statusText}`);
1752
- }
1753
- const body = await res.json();
1754
- return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
3133
+ this.connectPromise = this.attemptConnect().finally(() => {
3134
+ this.connectPromise = null;
3135
+ });
3136
+ return this.connectPromise;
3137
+ }
3138
+ /**
3139
+ * One discovery attempt, and the retry that follows it when it fails with
3140
+ * budget left. Runs inside the promise {@link connect} hands every caller.
3141
+ */
3142
+ async attemptConnect() {
3143
+ try {
3144
+ const baseUrl = this.baseUrl || "";
3145
+ const discoveryUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/api/v1/discovery` : "/api/v1/discovery";
3146
+ const data = await getSharedDiscovery(baseUrl, async () => {
3147
+ const res = await this.fetchImpl(discoveryUrl, {
3148
+ method: "GET",
3149
+ headers: this.token ? { Authorization: `Bearer ${this.token}` } : void 0
1755
3150
  });
1756
- this.client.discoveryInfo = data;
1757
- this.atomicBatchCapability = readTransactionalBatchCapability(data);
1758
- this.connected = true;
1759
- this.reconnectAttempts = 0;
1760
- this.setConnectionState("connected");
1761
- } catch (error) {
1762
- const errorMessage = error instanceof Error ? error.message : "Failed to connect to ObjectStack server";
1763
- const connectionError = new ConnectionError(
1764
- errorMessage,
1765
- void 0,
1766
- { originalError: error }
1767
- );
1768
- this.setConnectionState("error", connectionError);
1769
- if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
1770
- await this.attemptReconnect();
1771
- } else {
1772
- throw connectionError;
3151
+ if (!res.ok) {
3152
+ throw new Error(`discovery ${res.status} ${res.statusText}`);
1773
3153
  }
1774
- } finally {
1775
- this.connectPromise = null;
3154
+ const body = await res.json();
3155
+ return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
3156
+ });
3157
+ this.client.discoveryInfo = data;
3158
+ this.atomicBatchCapability = readTransactionalBatchCapability(data);
3159
+ this.connected = true;
3160
+ this.reconnectAttempts = 0;
3161
+ this.setConnectionState("connected");
3162
+ } catch (error) {
3163
+ const errorMessage = error instanceof Error ? error.message : "Failed to connect to ObjectStack server";
3164
+ const connectionError = new ConnectionError(
3165
+ errorMessage,
3166
+ void 0,
3167
+ { originalError: error }
3168
+ );
3169
+ this.setConnectionState("error", connectionError);
3170
+ if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
3171
+ await this.attemptReconnect();
3172
+ } else {
3173
+ throw connectionError;
1776
3174
  }
1777
- })();
1778
- return this.connectPromise;
3175
+ }
1779
3176
  }
1780
3177
  /**
1781
3178
  * Attempt to reconnect to the server with exponential backoff
@@ -1785,8 +3182,7 @@ var ObjectStackAdapter = class {
1785
3182
  this.setConnectionState("reconnecting");
1786
3183
  const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
1787
3184
  await new Promise((resolve) => setTimeout(resolve, delay));
1788
- this.connected = false;
1789
- await this.connect();
3185
+ await this.attemptConnect();
1790
3186
  }
1791
3187
  /**
1792
3188
  * Get the current connection state
@@ -1842,6 +3238,63 @@ var ObjectStackAdapter = class {
1842
3238
  }
1843
3239
  });
1844
3240
  }
3241
+ /**
3242
+ * Run one request through the adapter's fetch and let its outcome move the
3243
+ * connection state (objectui#12295). Before the first successful
3244
+ * {@link connect} this records nothing: `connect()` owns the state until
3245
+ * then, and its own discovery read passes through here too.
3246
+ *
3247
+ * - **Answered** — the fetch resolved with a Response, whatever its status.
3248
+ * A 4xx or 5xx is an answer: the server is reachable, so it counts the
3249
+ * same as a 200 and ends any outage.
3250
+ * - **Not answered** — the fetch rejected: no HTTP answer at all (refused,
3251
+ * reset, DNS, an offline browser). A rejection the caller asked for by
3252
+ * aborting is not evidence about the server and records nothing.
3253
+ *
3254
+ * The rejection or the Response reaches the caller unchanged.
3255
+ */
3256
+ async observeServerAnswer(send, init) {
3257
+ let response;
3258
+ try {
3259
+ response = await send();
3260
+ } catch (error) {
3261
+ if (!isCallerAbort(error, init)) this.noteServerUnanswered(error);
3262
+ throw error;
3263
+ }
3264
+ this.noteServerAnswered();
3265
+ return response;
3266
+ }
3267
+ /** An answered request after the first connect: the server is reachable. */
3268
+ noteServerAnswered() {
3269
+ if (!this.connected) return;
3270
+ this.reconnectAttempts = 0;
3271
+ if (this.connectionState !== "connected") this.setConnectionState("connected");
3272
+ }
3273
+ /**
3274
+ * An unanswered request after the first connect. It spends the adapter's own
3275
+ * retry window — the `autoReconnect` / `maxReconnectAttempts` budget that
3276
+ * {@link connect} grants a failed discovery — counted in the requests the
3277
+ * host already makes rather than in timed probes: while the budget lasts the
3278
+ * state is `reconnecting`, and the first unanswered request past it moves the
3279
+ * state to `error`. No timer and no request of the adapter's own is involved;
3280
+ * the next answered request, whenever it comes, restores `connected`.
3281
+ */
3282
+ noteServerUnanswered(error) {
3283
+ if (!this.connected || this.connectionState === "error") return;
3284
+ if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
3285
+ this.reconnectAttempts++;
3286
+ if (this.connectionState !== "reconnecting") this.setConnectionState("reconnecting");
3287
+ return;
3288
+ }
3289
+ this.setConnectionState(
3290
+ "error",
3291
+ new ConnectionError(
3292
+ error instanceof Error ? error.message : "ObjectStack server did not answer",
3293
+ void 0,
3294
+ { originalError: error }
3295
+ )
3296
+ );
3297
+ }
1845
3298
  /**
1846
3299
  * Emit batch progress event to listeners
1847
3300
  */
@@ -1863,9 +3316,7 @@ var ObjectStackAdapter = class {
1863
3316
  return { data: [], total: 0 };
1864
3317
  }
1865
3318
  const key = `${resource}::${stableStringify(params)}`;
1866
- const existing = this.inflightFinds.get(key);
1867
- if (existing) return existing;
1868
- const promise = (async () => {
3319
+ return this.shareInFlight(this.inflightFinds, key, async () => {
1869
3320
  await this.connect();
1870
3321
  if (params?.$expand && params.$expand.length > 0 || params?.$search != null && String(params.$search).trim() !== "") {
1871
3322
  const result = await this.rawFindWithPopulate(resource, params);
@@ -1882,11 +3333,25 @@ var ObjectStackAdapter = class {
1882
3333
  }
1883
3334
  throw err;
1884
3335
  }
1885
- })();
1886
- this.inflightFinds.set(key, promise);
3336
+ });
3337
+ }
3338
+ /**
3339
+ * Share one in-flight read among concurrent callers asking the same
3340
+ * question: the first call for `key` issues `read()`, every call that
3341
+ * arrives while it is still pending gets that same promise, and the entry is
3342
+ * dropped the moment it settles — so nothing is cached past the round trip,
3343
+ * a rejection reaches every caller that shared it and is not remembered, and
3344
+ * the next call after settle issues a fresh read. Used by `find` and
3345
+ * `findOne` (objectui#11699).
3346
+ */
3347
+ shareInFlight(inflight, key, read) {
3348
+ const existing = inflight.get(key);
3349
+ if (existing) return existing;
3350
+ const promise = read();
3351
+ inflight.set(key, promise);
1887
3352
  const cleanup = () => {
1888
- if (this.inflightFinds.get(key) === promise) {
1889
- this.inflightFinds.delete(key);
3353
+ if (inflight.get(key) === promise) {
3354
+ inflight.delete(key);
1890
3355
  }
1891
3356
  };
1892
3357
  promise.then(cleanup, cleanup);
@@ -1901,7 +3366,7 @@ var ObjectStackAdapter = class {
1901
3366
  * — a per-object metadata-driven search (ADR-0061) — this consults the search
1902
3367
  * index and ranks hits across objects, so it surfaces records the per-object
1903
3368
  * fanout misses. Global affordances (⌘K command palette, search page) prefer
1904
- * this path (framework #3371).
3369
+ * this path (objectstack-ai/objectstack#3371).
1905
3370
  *
1906
3371
  * Returns `{ query, hits }`. A backend without the search plugin installed
1907
3372
  * answers `404`; we treat that as "no global search here" and return an empty
@@ -1934,10 +3399,10 @@ var ObjectStackAdapter = class {
1934
3399
  if (!res.ok) {
1935
3400
  if (res.status === 404) return { query: trimmed, hits: [] };
1936
3401
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
1937
- const err = new Error(
1938
- errorBody?.error?.message || errorBody?.message || res.statusText
1939
- );
3402
+ const envelope = readErrorEnvelope(errorBody);
3403
+ const err = new Error(envelope.message ?? res.statusText);
1940
3404
  err.status = res.status;
3405
+ err.code = envelope.code;
1941
3406
  throw err;
1942
3407
  }
1943
3408
  const body = await res.json();
@@ -1965,8 +3430,16 @@ var ObjectStackAdapter = class {
1965
3430
  }
1966
3431
  /**
1967
3432
  * Find a single record by ID.
3433
+ *
3434
+ * Concurrent calls for the same resource, id and params share one request,
3435
+ * exactly as `find` does (objectui#11699); see {@link shareInFlight}.
1968
3436
  */
1969
3437
  async findOne(resource, id, params) {
3438
+ const key = `${resource}::${JSON.stringify(String(id))}::${stableStringify(params)}`;
3439
+ return this.shareInFlight(this.inflightFindOnes, key, () => this.readOne(resource, id, params));
3440
+ }
3441
+ /** The one round trip behind {@link findOne}, unshared. */
3442
+ async readOne(resource, id, params) {
1970
3443
  await this.connect();
1971
3444
  if (params?.$expand && params.$expand.length > 0) {
1972
3445
  try {
@@ -2006,6 +3479,10 @@ var ObjectStackAdapter = class {
2006
3479
  * mutation or starve the other subscribers, so each is isolated.
2007
3480
  */
2008
3481
  emitMutation(event) {
3482
+ const prefix = `${event.resource}::`;
3483
+ for (const key of Array.from(this.inflightFindOnes.keys())) {
3484
+ if (key.startsWith(prefix)) this.inflightFindOnes.delete(key);
3485
+ }
2009
3486
  for (const listener of this.mutationListeners) {
2010
3487
  try {
2011
3488
  listener(event);
@@ -2040,24 +3517,41 @@ var ObjectStackAdapter = class {
2040
3517
  }
2041
3518
  }
2042
3519
  /**
2043
- * Read `droppedFields` off a create/update response (framework #3431/#3455)
3520
+ * Read `droppedFields` off a create/update response (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455)
2044
3521
  * and, when present, notify write-warning subscribers. Tolerant of a client
2045
3522
  * whose response type predates `droppedFields`: the field is read structurally
2046
3523
  * and validated, so an older client (or a backend that never drops) is a no-op.
3524
+ *
3525
+ * SHAPE decides whether an entry is an event at all (it must name at least one
3526
+ * field); `object`, `fields` and `reason` are then PARSED — an unrecognized
3527
+ * reason routed to the skew arm, a non-string field element refused, a missing
3528
+ * `object` healed from `resource` — never asserted into the union, and the
3529
+ * entry itself never dropped for them (objectui#4934, `f75810e7c`).
3530
+ *
3531
+ * The same answer's `warnings` (objectui#12107) rides this one event: it is
3532
+ * emitted when either list is non-empty, so a write whose only news is an
3533
+ * advisory rule hit still reaches the subscriber, with `droppedFields: []`.
2047
3534
  */
2048
3535
  notifyDroppedFields(operation, resource, result, id, sent) {
2049
3536
  const dropped = result?.droppedFields;
2050
- if (!Array.isArray(dropped) || dropped.length === 0) return;
2051
- const valid = dropped.filter(
2052
- (e) => !!e && typeof e === "object" && Array.isArray(e.fields) && e.fields.length > 0
2053
- );
2054
- const stored = result?.record;
2055
- const droppedFields = withoutNoOpDrops(valid, sent, stored);
2056
- if (droppedFields.length === 0) return;
2057
- this.emitWriteWarning({ operation, resource, ...id !== void 0 ? { id } : {}, droppedFields });
3537
+ let droppedFields = [];
3538
+ if (Array.isArray(dropped) && dropped.length > 0) {
3539
+ const valid = dropped.filter(isWireDroppedFieldsEntry).map((e) => asDroppedFieldsNotice(e, resource));
3540
+ const stored = result?.record;
3541
+ droppedFields = withoutNoOpDrops(valid, sent, stored);
3542
+ }
3543
+ const warnings = readWriteWarnings(result);
3544
+ if (droppedFields.length === 0 && warnings.length === 0) return;
3545
+ this.emitWriteWarning({
3546
+ operation,
3547
+ resource,
3548
+ ...id !== void 0 ? { id } : {},
3549
+ droppedFields,
3550
+ ...warnings.length > 0 ? { warnings } : {}
3551
+ });
2058
3552
  }
2059
3553
  /**
2060
- * Same, for the cross-object transactional batch (framework #3794). Its
3554
+ * Same, for the cross-object transactional batch (objectstack-ai/objectstack#3794). Its
2061
3555
  * response hangs the events off a top-level `droppedFields` list, each tagged
2062
3556
  * with the `index` of the operation it came from — `results` entries are bare
2063
3557
  * record echoes with nowhere to hang a per-row list.
@@ -2073,13 +3567,13 @@ var ObjectStackAdapter = class {
2073
3567
  if (!Array.isArray(dropped) || dropped.length === 0) return;
2074
3568
  const results = payload?.results;
2075
3569
  for (const entry of dropped) {
2076
- if (!entry || typeof entry !== "object") continue;
3570
+ if (!isWireDroppedFieldsEntry(entry)) continue;
2077
3571
  const e = entry;
2078
- if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
2079
3572
  const op = typeof e.index === "number" ? operations[e.index] : void 0;
3573
+ const object = typeof e.object === "string" ? e.object : typeof op?.object === "string" ? op.object : UNATTRIBUTED_STRIP_OBJECT;
2080
3574
  const stored = typeof e.index === "number" && Array.isArray(results) ? results[e.index] : void 0;
2081
3575
  const [live] = withoutNoOpDrops(
2082
- [{ object: e.object, fields: e.fields, reason: e.reason }],
3576
+ [asDroppedFieldsNotice({ fields: e.fields, reason: e.reason }, object)],
2083
3577
  op?.data,
2084
3578
  stored
2085
3579
  );
@@ -2087,7 +3581,7 @@ var ObjectStackAdapter = class {
2087
3581
  const operation = (op?.action ?? "create") === "create" ? "create" : "update";
2088
3582
  this.emitWriteWarning({
2089
3583
  operation,
2090
- resource: e.object ?? op?.object ?? "",
3584
+ resource: object,
2091
3585
  ...op?.id !== void 0 && op?.id !== null ? { id: op.id } : {},
2092
3586
  droppedFields: [live]
2093
3587
  });
@@ -2095,7 +3589,7 @@ var ObjectStackAdapter = class {
2095
3589
  }
2096
3590
  /**
2097
3591
  * Subscribe to write-warning events (a create/update dropped caller-supplied
2098
- * fields — #3431/#3455). Returns an unsubscribe function. The app shell uses
3592
+ * fields — objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455). Returns an unsubscribe function. The app shell uses
2099
3593
  * this to toast the user; the write itself already succeeded.
2100
3594
  */
2101
3595
  onWriteWarning(callback) {
@@ -2109,7 +3603,7 @@ var ObjectStackAdapter = class {
2109
3603
  * advisory findings on a save that SUCCEEDED (#4237; backend
2110
3604
  * objectstack#7435). Returns an unsubscribe function.
2111
3605
  *
2112
- * Deliberately the same seam as {@link onWriteWarning} (#3431/#3455), which
3606
+ * Deliberately the same seam as {@link onWriteWarning} (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455), which
2113
3607
  * is what {@link MetadataSaveAdvisoryEvent}'s own declaration already said it
2114
3608
  * was modelled on. It is a SIBLING of that channel rather than a second
2115
3609
  * payload pushed down it: `WriteWarningEvent` is a closed shape whose
@@ -2146,6 +3640,45 @@ var ObjectStackAdapter = class {
2146
3640
  }
2147
3641
  }
2148
3642
  }
3643
+ /**
3644
+ * Subscribe to metadata read-warning events — a metadata READ that could not
3645
+ * be answered and was degraded to an empty result anyway (objectui#7741).
3646
+ * Returns an unsubscribe function. The app shell uses this to toast the user;
3647
+ * the caller has already been handed the empty result.
3648
+ *
3649
+ * Deliberately the same seam as {@link onWriteWarning} and
3650
+ * {@link onSaveAdvisory}, and a SIBLING of them rather than a payload pushed
3651
+ * down either: both of those announce a write that SUCCEEDED, and an event
3652
+ * saying "this read did not happen" carried on one of them would make it lie
3653
+ * about what occurred. What is reused is the seam's SHAPE — one long-lived
3654
+ * instance with a `subscribe -> unsubscribe` registration that
3655
+ * `AdapterProvider` wires once — not its event type.
3656
+ *
3657
+ * ⛔ Nothing here changes what a caller RECEIVES. `listImportMappings` still
3658
+ * answers `[]` on every failure, including the loud ones; this channel is
3659
+ * added ALONGSIDE that return, which is why it is not a change to a surface
3660
+ * published since `@object-ui/data-objectstack@17.1.0`.
3661
+ */
3662
+ onMetadataReadWarning(callback) {
3663
+ this.metadataReadWarningListeners.add(callback);
3664
+ return () => {
3665
+ this.metadataReadWarningListeners.delete(callback);
3666
+ };
3667
+ }
3668
+ /**
3669
+ * Notify all metadata read-warning subscribers. Isolated exactly like
3670
+ * {@link emitWriteWarning}: a throwing listener must neither break the caller
3671
+ * nor starve the others.
3672
+ */
3673
+ emitMetadataReadWarning(event) {
3674
+ for (const listener of this.metadataReadWarningListeners) {
3675
+ try {
3676
+ listener(event);
3677
+ } catch (err) {
3678
+ console.warn("ObjectStackAdapter: metadata read-warning listener error", err);
3679
+ }
3680
+ }
3681
+ }
2149
3682
  /**
2150
3683
  * Install the ONE emitter for the metadata save door (#4237).
2151
3684
  *
@@ -2153,8 +3686,8 @@ var ObjectStackAdapter = class {
2153
3686
  *
2154
3687
  * `ObjectStackClient.meta.saveItem` is the second client class that writes
2155
3688
  * 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
3689
+ * it through an adapter this class constructed — the three inside this file
3690
+ * (the two view paths, `updateDashboard`) via
2158
3691
  * `this.client`, and every caller outside it via {@link getClient}, which
2159
3692
  * hands back this same instance: `MetadataService` (app-shell, five saves),
2160
3693
  * `useNavigationSync`, and plugin-designer's Create/EditAppPage. Wrapping the
@@ -2183,26 +3716,42 @@ var ObjectStackAdapter = class {
2183
3716
  *
2184
3717
  * Drafts are NEVER gated: the framework returns at its D1 early-return
2185
3718
  * (`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.
3719
+ * draft save produces no findings to withhold. `mode` on the emitted event is
3720
+ * derived from the response's own `state`, not from the caller's options:
3721
+ * `'draft'` when the server says the row landed as a draft, `'publish'`
3722
+ * otherwise. The server's answer is the one that says which door the write
3723
+ * took, so the event cannot claim a door the request only asked for.
3724
+ *
3725
+ * ## Every argument is forwarded (objectui#12300)
3726
+ *
3727
+ * The SDK's `saveItem(type, name, item, options?)` carries the write's scope
3728
+ * in its fourth argument, `SaveMetaItemOptions`: `packageId` (`?package=`),
3729
+ * `force`, `mode` and `ifMatch`. This wrapper used to name three parameters
3730
+ * and call the original with three, so that bag was dropped before the SDK
3731
+ * saw it. Every caller reaching the SDK through {@link getClient} got a bare
3732
+ * `PUT` whatever it passed, while the declared signature still accepted the
3733
+ * bag, so nothing at the call site could tell. The wrapper now takes and
3734
+ * passes the original's own parameter list, so it forwards whatever the SDK
3735
+ * declares, today's bag and any later member, without naming one.
2194
3736
  */
2195
3737
  installSaveAdvisoryInterceptor() {
2196
3738
  const meta = this.client.meta;
2197
3739
  const original = meta.saveItem.bind(meta);
2198
- meta.saveItem = async (type, name, item) => {
2199
- const result = await original(type, name, item);
3740
+ meta.saveItem = async (...args) => {
3741
+ const [type, name] = args;
3742
+ const result = await original(...args);
2200
3743
  try {
2201
3744
  const advisories = readSaveAdvisories(result);
2202
3745
  if (advisories.length > 0) {
2203
3746
  this.emitSaveAdvisory({
2204
3747
  type,
2205
3748
  name,
3749
+ // #5026 — this interceptor wraps `meta.saveItem`, the SAVE door
3750
+ // (`PUT /meta/:type/:name`) and only that one. The SDK's publish
3751
+ // door (`meta.publishItem`) has no caller in this repo, so wiring
3752
+ // it here would be a surface with no consumer; `MetadataClient` is
3753
+ // where the publish door is actually taken.
3754
+ door: "save",
2206
3755
  mode: result?.state === "draft" ? "draft" : "publish",
2207
3756
  advisories
2208
3757
  });
@@ -2213,6 +3762,61 @@ var ObjectStackAdapter = class {
2213
3762
  return result;
2214
3763
  };
2215
3764
  }
3765
+ /**
3766
+ * Route this adapter's client's published view-list read through the one
3767
+ * {@link VIEW_LIST_KEY} (objectui#13044).
3768
+ *
3769
+ * The app-shell `MetadataProvider` reads `client.meta.getItems('view')`
3770
+ * through {@link getClient}; {@link listViews} and {@link listViewOverrides}
3771
+ * read the same `GET /api/v1/meta/view`. Each used to put its own request on
3772
+ * the wire, so one list-page open asked the server three times for one URL.
3773
+ *
3774
+ * A caller of the client keeps its contract, "ask the server": it is never
3775
+ * answered from a settled entry. It joins a request already in flight for
3776
+ * the same URL instead of sending a second one, and its answer is stored
3777
+ * under {@link VIEW_LIST_KEY} (see {@link MetadataCache.share}), which is
3778
+ * what {@link readViewList} answers this adapter's own readers from
3779
+ * (objectui#13057). Only the bare call is routed: any argument after the
3780
+ * type changes the URL, so such a call goes straight to the SDK.
3781
+ *
3782
+ * Every caller gets its own copy of the answer. Before this, every reader
3783
+ * got its own parsed response, so a reader that changed what it was handed
3784
+ * changed nothing anyone else read; a shared answer must not change that.
3785
+ *
3786
+ * Same wrap shape and the same bound as `installSaveAdvisoryInterceptor`:
3787
+ * `meta` is an own property of a client this adapter built and never shares.
3788
+ */
3789
+ installViewListShare() {
3790
+ const meta = this.client.meta;
3791
+ const original = meta.getItems.bind(meta);
3792
+ meta.getItems = ((...args) => {
3793
+ if (args[0] !== "view" || args.length > 1) return original(...args);
3794
+ return this.metadataCache.share(VIEW_LIST_KEY, () => original(...args)).then((answer) => structuredClone(answer));
3795
+ });
3796
+ for (const door of ["saveItem", "deleteItem", "publishItem", "rollbackItem"]) {
3797
+ const write = meta[door];
3798
+ if (typeof write !== "function") continue;
3799
+ const bound = write.bind(meta);
3800
+ meta[door] = (...args) => {
3801
+ if (args[0] !== "view") return bound(...args);
3802
+ return bound(...args).finally(() => this.metadataCache.invalidate?.(VIEW_LIST_KEY));
3803
+ };
3804
+ }
3805
+ }
3806
+ /**
3807
+ * The published view list for this adapter's own readers
3808
+ * ({@link listViews}, {@link listViewOverrides}): the answer stored under
3809
+ * {@link VIEW_LIST_KEY} while it holds one (objectui#13057), else the
3810
+ * client's read, which joins the request in flight for that key or starts
3811
+ * one, and stores its answer (objectui#13044). Always a copy the caller may
3812
+ * keep. Every path that should see a fresh list drops the key first; the
3813
+ * list is in {@link VIEW_LIST_KEY}'s doc.
3814
+ */
3815
+ readViewList() {
3816
+ const settled = this.metadataCache.getCachedSync(VIEW_LIST_KEY);
3817
+ if (settled !== void 0) return Promise.resolve(structuredClone(settled));
3818
+ return this.client.meta.getItems("view");
3819
+ }
2216
3820
  async create(resource, data) {
2217
3821
  await this.connect();
2218
3822
  try {
@@ -2250,7 +3854,7 @@ var ObjectStackAdapter = class {
2250
3854
  data,
2251
3855
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
2252
3856
  );
2253
- this.emitMutation({ type: "update", resource, id, record: { ...result.record } });
3857
+ this.emitMutation({ type: "update", resource, id: String(id), record: { ...result.record } });
2254
3858
  this.notifyDroppedFields("update", resource, result, id, data);
2255
3859
  return result.record;
2256
3860
  } catch (err) {
@@ -2273,7 +3877,7 @@ var ObjectStackAdapter = class {
2273
3877
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
2274
3878
  );
2275
3879
  if (result.success) {
2276
- this.emitMutation({ type: "delete", resource, id });
3880
+ this.emitMutation({ type: "delete", resource, id: String(id) });
2277
3881
  }
2278
3882
  return result.success;
2279
3883
  } catch (err) {
@@ -2374,25 +3978,25 @@ var ObjectStackAdapter = class {
2374
3978
  * @returns Promise resolving to array of results
2375
3979
  */
2376
3980
  /**
2377
- * Cross-object transactional batch (ObjectStack #1604 / ADR-0034 item 4).
3981
+ * Cross-object transactional batch (objectstack-ai/objectstack#1604 / ADR-0034 item 4).
2378
3982
  * Runs the operations in ONE server transaction — commit all or roll back
2379
3983
  * all. A field value of `{ $ref: <earlier op index> }` resolves to that op's
2380
3984
  * created id, so a child can reference its parent created earlier in the same
2381
3985
  * batch (master-detail).
2382
3986
  *
2383
3987
  * Transport: the published `@objectstack/client` SDK method
2384
- * `data.batchTransaction` (framework #3271; shipped since client v16, our
3988
+ * `data.batchTransaction` (objectstack-ai/objectstack#3271; shipped since client v16, our
2385
3989
  * dependency floor). Per AGENTS.md §7 data always flows through the client —
2386
3990
  * never a hand-rolled `fetch('/api/v1/batch')`.
2387
3991
  *
2388
- * Fallback decision — declarative capability negotiation (framework #3298 /
3992
+ * Fallback decision — declarative capability negotiation (objectstack-ai/objectstack#3298 /
2389
3993
  * objectui #2693). At connect() we read `capabilities.transactionalBatch`
2390
3994
  * from discovery:
2391
3995
  * - Declared `true` → the backend GUARANTEES atomicity (declared ===
2392
3996
  * enforced). We TRUST it: any batch failure — including 404/405/501 —
2393
3997
  * surfaces as a real error. No non-atomic client-side compensation. This
2394
3998
  * is the path modern backends take.
2395
- * - Declared `false`, or ABSENT (backend predates #3298) → we can't rely on
3999
+ * - Declared `false`, or ABSENT (backend predates objectstack-ai/objectstack#3298) → we can't rely on
2396
4000
  * server atomicity, so we keep the legacy behaviour: on 404/405 (no
2397
4001
  * endpoint) or 501 (runtime without transactions) degrade to the
2398
4002
  * client-side, NON-atomic {@link emulateBatchTransaction} so a save is
@@ -2816,9 +4420,10 @@ var ObjectStackAdapter = class {
2816
4420
  const res = await this.fetchImpl(url, { method: "GET", headers });
2817
4421
  if (!res.ok) {
2818
4422
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
2819
- const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
4423
+ const envelope = readErrorEnvelope(errorBody);
4424
+ const err = new Error(envelope.message ?? res.statusText);
2820
4425
  err.status = res.status;
2821
- err.code = errorBody?.code ?? errorBody?.error?.code;
4426
+ err.code = envelope.code;
2822
4427
  err.httpStatus = res.status;
2823
4428
  throw err;
2824
4429
  }
@@ -2868,16 +4473,51 @@ var ObjectStackAdapter = class {
2868
4473
  queryParams.set("searchFields", request.searchFields.join(","));
2869
4474
  }
2870
4475
  }
4476
+ return this.fetchExportBlob(resource, queryParams);
4477
+ }
4478
+ /**
4479
+ * Download the server's import template:
4480
+ * `GET /api/v1/data/:object/export?template=true` (objectui#9600).
4481
+ *
4482
+ * Same route as {@link exportDownload}, with the server's template switch. It
4483
+ * sends only `template=true`: the server refuses the export's row parameters
4484
+ * on a template request, and its answer is always an xlsx workbook.
4485
+ *
4486
+ * The server writes the template's labels in the request's locale, and the
4487
+ * import accepts a translated label only in its own request's locale. The
4488
+ * import request goes through `@objectstack/client`, which stamps
4489
+ * `Accept-Language` with the client's locale when one is set (and the host's
4490
+ * `fetch` stamps its own otherwise). This request bypasses the client, so it
4491
+ * stamps the client's locale the same way. Both requests then carry the same
4492
+ * locale.
4493
+ */
4494
+ async downloadImportTemplate(resource) {
4495
+ const locale = this.client.getLocale();
4496
+ return this.fetchExportBlob(
4497
+ resource,
4498
+ new URLSearchParams({ template: "true" }),
4499
+ locale ? { "Accept-Language": locale } : void 0
4500
+ );
4501
+ }
4502
+ /**
4503
+ * GET `/api/v1/data/:object/export` with the given query and answer the body
4504
+ * as a Blob. A failure throws an `Error` that carries the server's `status`
4505
+ * and its ADR-0112 `code`. The one request path {@link exportDownload} and
4506
+ * {@link downloadImportTemplate} share.
4507
+ */
4508
+ async fetchExportBlob(resource, queryParams, extraHeaders) {
2871
4509
  const baseUrl = this.baseUrl.replace(/\/$/, "");
2872
4510
  const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
2873
4511
  const dataPath = hasApiVersionSuffix ? "/data" : "/api/v1/data";
2874
4512
  const url = `${baseUrl}${dataPath}/${encodeURIComponent(resource)}/export?${queryParams.toString()}`;
2875
- const headers = { ...this.getAuthHeaders() };
4513
+ const headers = { ...this.getAuthHeaders(), ...extraHeaders };
2876
4514
  const res = await this.fetchImpl(url, { method: "GET", headers, credentials: "include" });
2877
4515
  if (!res.ok) {
2878
4516
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
2879
- const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
4517
+ const envelope = readErrorEnvelope(errorBody);
4518
+ const err = new Error(envelope.message ?? res.statusText);
2880
4519
  err.status = res.status;
4520
+ err.code = envelope.code;
2881
4521
  throw err;
2882
4522
  }
2883
4523
  return await res.blob();
@@ -2898,7 +4538,8 @@ var ObjectStackAdapter = class {
2898
4538
  if (Array.isArray(params.$filter)) {
2899
4539
  options.filters = translateFilterArray(params.$filter);
2900
4540
  } else {
2901
- options.filters = convertFiltersToAST(params.$filter);
4541
+ const lowered = convertFiltersToAST(params.$filter);
4542
+ if (lowered !== void 0) options.filters = lowered;
2902
4543
  }
2903
4544
  }
2904
4545
  }
@@ -2933,7 +4574,6 @@ var ObjectStackAdapter = class {
2933
4574
  () => this.fetchObjectSchemaFresh(objectName)
2934
4575
  );
2935
4576
  normalizeSchemaReferenceKeys(schema);
2936
- this.applyFieldWidgetOverrides(objectName, schema);
2937
4577
  return schema;
2938
4578
  } catch (error) {
2939
4579
  const errorObj = error;
@@ -2946,44 +4586,6 @@ var ObjectStackAdapter = class {
2946
4586
  throw createErrorFromResponse(errorObj, `getObjectSchema(${objectName})`);
2947
4587
  }
2948
4588
  }
2949
- /**
2950
- * ADR-0056 P2 (epic #2398) — stamp structured-widget hints onto platform
2951
- * fields whose framework type is a storage primitive (e.g. `textarea`) but
2952
- * whose authoring UX should be a structured editor. Only the render `widget`
2953
- * is added; the field's `type` (the storage contract) is untouched. Applied
2954
- * idempotently to the cached schema so form + detail both honor it. Widget
2955
- * components are registered as `field:<widget>` in `@object-ui/fields`.
2956
- */
2957
- applyFieldWidgetOverrides(objectName, schema) {
2958
- const OVERRIDES = {
2959
- // ADR-0056 pure model — a permission set's six authorization facets are
2960
- // *designed* in Studio's structured editors and only *assigned* (to
2961
- // users) in Setup. In Setup they render read-only as a summary + a
2962
- // "Design in Studio →" deep-link (the `permission-facet-link` widget),
2963
- // never as raw [Object]/JSON. The capability *editor* itself lives in
2964
- // Studio (epic #2398 P2).
2965
- sys_permission_set: {
2966
- object_permissions: "permission-facet-link",
2967
- field_permissions: "permission-facet-link",
2968
- system_permissions: "permission-facet-link",
2969
- row_level_security: "permission-facet-link",
2970
- tab_permissions: "permission-facet-link",
2971
- admin_scope: "permission-facet-link"
2972
- }
2973
- };
2974
- const overrides = OVERRIDES[objectName];
2975
- const fields = schema && typeof schema === "object" ? schema.fields : null;
2976
- if (!overrides || !fields) return;
2977
- for (const [fname, widget] of Object.entries(overrides)) {
2978
- if (Array.isArray(fields)) {
2979
- const f = fields.find((x) => x?.name === fname);
2980
- if (f && !f.widget) f.widget = widget;
2981
- } else {
2982
- const f = fields[fname];
2983
- if (f && !f.widget) f.widget = widget;
2984
- }
2985
- }
2986
- }
2987
4589
  /**
2988
4590
  * Fetch a single object's schema while always revalidating the browser cache.
2989
4591
  *
@@ -3018,13 +4620,16 @@ var ObjectStackAdapter = class {
3018
4620
  });
3019
4621
  if (!res.ok) {
3020
4622
  const errBody = await res.json().catch(() => ({ message: res.statusText }));
3021
- const err = new Error(errBody?.error?.message || errBody?.message || res.statusText);
4623
+ const envelope = readErrorEnvelope(errBody);
4624
+ const err = new Error(envelope.message ?? res.statusText);
3022
4625
  err.status = res.status;
4626
+ err.code = envelope.code;
3023
4627
  throw err;
3024
4628
  }
3025
4629
  const body = await res.json();
3026
4630
  const data = body && typeof body === "object" && "success" in body && "data" in body ? body.data : body;
3027
- return data && typeof data === "object" && "item" in data ? data.item : data;
4631
+ const item = data && typeof data === "object" && "item" in data ? data.item : data;
4632
+ return attachObjectSortability(item, data && typeof data === "object" ? data.sortability : void 0);
3028
4633
  }
3029
4634
  /**
3030
4635
  * List every registered object (code- and DB-defined) from the metadata
@@ -3055,6 +4660,38 @@ var ObjectStackAdapter = class {
3055
4660
  return [];
3056
4661
  }
3057
4662
  }
4663
+ /**
4664
+ * List the items one metadata registry type declares —
4665
+ * `GET /api/v1/meta/:type`, through the client's `meta.getItems` — as
4666
+ * `{ name, label }` headers for the `position-ref` / `permission-ref` field
4667
+ * widgets (objectui#7611).
4668
+ *
4669
+ * Unlike {@link getObjects}, a failure REJECTS (the `DataSource` contract):
4670
+ * the transport's own error propagates, and an answer without the spec's
4671
+ * `items` list is refused rather than read as an empty registry, so a
4672
+ * failed load can never reach the widget as "this registry declares
4673
+ * nothing". Uncached, like `getObjects`: an item authored in Studio is
4674
+ * listed on the next form that opens.
4675
+ *
4676
+ * An item without a string `name` is not listed (the widget stores the
4677
+ * name, so there is nothing to store); a `label` that is not a string is
4678
+ * left off rather than coerced.
4679
+ */
4680
+ async listMetadataItems(type) {
4681
+ await this.connect();
4682
+ const result = await this.client.meta.getItems(type);
4683
+ if (!Array.isArray(result?.items)) {
4684
+ throw new Error(`GET /meta/${type} answered without an items list`);
4685
+ }
4686
+ const out = [];
4687
+ for (const it of result.items) {
4688
+ const name = it?.name;
4689
+ if (typeof name !== "string" || !name) continue;
4690
+ const label = it.label;
4691
+ out.push(typeof label === "string" && label ? { name, label } : { name });
4692
+ }
4693
+ return out;
4694
+ }
3058
4695
  /**
3059
4696
  * Get access to the underlying ObjectStack client for advanced operations.
3060
4697
  */
@@ -3121,14 +4758,16 @@ var ObjectStackAdapter = class {
3121
4758
  invalidateViewKeys(objectName, viewName) {
3122
4759
  this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
3123
4760
  this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
4761
+ this.metadataCache.invalidate?.(VIEW_LIST_KEY);
3124
4762
  }
3125
4763
  /**
3126
4764
  * Batch-fetch all persisted view overrides for an object.
3127
4765
  *
3128
4766
  * 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
4767
+ * columns, inlineEdit …) live in the SAME metadata namespace the view
4768
+ * writes use: `type='view'`, `name=<viewId>` (see {@link createView} /
4769
+ * {@link updateView}; the toolbar's `updateViewConfig` wrote overlays there
4770
+ * until objectui#12504 retired it). Loading them per-view fires N HTTP GETs
3132
4771
  * that 404 for every view the user never customized — console noise on
3133
4772
  * every page load. This batch method performs a single
3134
4773
  * `GET /api/v1/meta/view` (returns `{type, items}`) and narrows the
@@ -3152,9 +4791,9 @@ var ObjectStackAdapter = class {
3152
4791
  *
3153
4792
  * Result is cached identically to {@link getView}, and EVERY view write —
3154
4793
  * 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
4794
+ * {@link invalidateViewKeys}. For a long time only `updateViewConfig` (since
4795
+ * retired, objectui#12504) did (objectui#4363), which left the other three
4796
+ * adapter paths stale for the cache's 5-minute TTL. That gap does not self-heal: the consumer
3158
4797
  * (`loadViewOverrides`, app-shell `ObjectView`) treats a RESOLVED map as
3159
4798
  * authoritative and deliberately does not re-probe per view — objectui#3774,
3160
4799
  * and correct, since re-probing reinstates the 404 flurry the batch read
@@ -3170,7 +4809,7 @@ var ObjectStackAdapter = class {
3170
4809
  await this.connect();
3171
4810
  const cacheKey = `view-overrides:${objectName}`;
3172
4811
  return await this.metadataCache.get(cacheKey, async () => {
3173
- const result = await this.client.meta.getItems("view");
4812
+ const result = await this.readViewList();
3174
4813
  const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
3175
4814
  const out = {};
3176
4815
  for (const it of items) {
@@ -3205,69 +4844,6 @@ var ObjectStackAdapter = class {
3205
4844
  return null;
3206
4845
  }
3207
4846
  }
3208
- /**
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}.
3227
- *
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).
3242
- *
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.
3246
- *
3247
- * @param objectName - Object name (e.g. 'lead')
3248
- * @param viewId - View identifier (e.g. 'all_leads')
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.
3253
- */
3254
- async updateViewConfig(objectName, viewId, config, opts) {
3255
- await this.connect();
3256
- const merged = {
3257
- ...config || {},
3258
- object: config?.object || objectName,
3259
- name: viewId,
3260
- ...opts?.isSavedView ? {} : { [VIEW_OVERLAY_MARKER]: true }
3261
- };
3262
- const result = await this.client.meta.saveItem(
3263
- "view",
3264
- viewId,
3265
- merged
3266
- );
3267
- this.invalidateViewKeys(objectName, viewId);
3268
- if (result && result.item) return result.item;
3269
- return result ?? void 0;
3270
- }
3271
4847
  /**
3272
4848
  * List user-created views for a given object via the metadata overlay
3273
4849
  * API (ADR-0005). Replaces the legacy `find('sys_view', {...})` path
@@ -3279,6 +4855,42 @@ var ObjectStackAdapter = class {
3279
4855
  * the metadata index is name-only, not field-typed, so the route has no
3280
4856
  * `?object=` to push the filter down into. {@link listViewOverrides}
3281
4857
  * reads the same rows through the same accessor.
4858
+ *
4859
+ * ## Every failure still degrades to an empty list — and now says which kind
4860
+ * ## of failure it was (objectui#8151)
4861
+ *
4862
+ * The degrade is unchanged and deliberate, exactly as it is one method over
4863
+ * in {@link listImportMappings}: a host with no metadata door keeps answering
4864
+ * `[]` rather than breaking the page. What changed is that the OTHER failures
4865
+ * no longer render as that one.
4866
+ *
4867
+ * This method's empty list is read as *this object has no saved views*, and
4868
+ * that reading reaches the user in two places measured on this tree: the list
4869
+ * view switcher simply shows fewer tabs, and `@object-ui/core`'s
4870
+ * `elementDataSourceViewNotFoundMessage` states it outright. So a session
4871
+ * whose token lapsed mid-read was shown an object that appears to have no
4872
+ * saved views AT ALL — including ones the user created — with a
4873
+ * `console.warn` as the only discriminator, in a console nothing in the UI
4874
+ * points at. That is the silence objectui#7741 removed from the sibling after
4875
+ * it produced a confident wrong diagnosis in objectstack#14026.
4876
+ *
4877
+ * The `catch` now asks {@link classifyViewsFailure} what the failure WAS,
4878
+ * reading the error's own ADR-0112 `code` and status — never "is the result
4879
+ * empty", which is what both conditions produce and so can never tell them
4880
+ * apart — and anything that is not the doorless shape is announced on
4881
+ * {@link onMetadataReadWarning}.
4882
+ *
4883
+ * ⛔ That classifier is a SEPARATE reading, not a second caller of the
4884
+ * sibling's: `view`'s quiet set is strictly smaller, because the arm
4885
+ * `listImportMappings` is built around (*an older server without this kind*)
4886
+ * has no members here. See {@link classifyViewsFailure} for the measurement.
4887
+ *
4888
+ * ⛔ The RETURN is untouched. This method has answered `Promise<any[]>`, never
4889
+ * throwing, since `@object-ui/data-objectstack@17.1.0`; a consumer that reads
4890
+ * nothing new sees exactly what it saw before, including on the loud arms.
4891
+ * Applying framework objectstack#13906 decision 1 option A — *a thing that could not be
4892
+ * READ is not a thing that is ABSENT* — is done by ADDING a channel, not by
4893
+ * moving that contract.
3282
4894
  */
3283
4895
  async listViews(objectName, options) {
3284
4896
  await this.connect();
@@ -3288,7 +4900,7 @@ var ObjectStackAdapter = class {
3288
4900
  const draftItems = await this.metadataClient().withPreviewDrafts(true).list("view");
3289
4901
  items = Array.isArray(draftItems) ? draftItems : [];
3290
4902
  } else {
3291
- const result = await this.client.meta.getItems("view");
4903
+ const result = await this.readViewList();
3292
4904
  items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
3293
4905
  }
3294
4906
  const FORM_FAMILY = /* @__PURE__ */ new Set(["form", "detail"]);
@@ -3304,18 +4916,35 @@ var ObjectStackAdapter = class {
3304
4916
  const spec = v.list ?? v;
3305
4917
  const isDraft = v._draft === true || spec?._draft === true;
3306
4918
  if (spec && spec.config && typeof spec.config === "object") {
3307
- return {
4919
+ const row = {
3308
4920
  ...spec.config,
3309
4921
  name: spec.name ?? spec.config.name,
3310
- label: spec.label ?? spec.config.label,
3311
- isDefault: !!spec.isDefault,
3312
- ...isDraft ? { _draft: true } : {}
4922
+ label: spec.label ?? spec.config.label
3313
4923
  };
4924
+ if (spec.object !== void 0) row.object = spec.object;
4925
+ for (const key of VIEW_ITEM_ROUND_TRIP_KEYS) {
4926
+ if (spec[key] !== void 0) row[key] = spec[key];
4927
+ }
4928
+ row.isDefault = !!spec.isDefault;
4929
+ if (isDraft) row._draft = true;
4930
+ return row;
3314
4931
  }
3315
4932
  return isDraft ? { ...spec, _draft: true } : spec;
3316
4933
  });
3317
4934
  } catch (err) {
3318
4935
  console.warn("[OBJECTSTACKDataSource] listViews failed:", err);
4936
+ const failure = classifyViewsFailure(err);
4937
+ if (failure.kind !== "not-served") {
4938
+ this.emitMetadataReadWarning({
4939
+ operation: "listViews",
4940
+ kind: "view",
4941
+ objectName,
4942
+ reason: failure.kind,
4943
+ ...failure.code !== void 0 ? { code: failure.code } : {},
4944
+ ...failure.status !== void 0 ? { status: failure.status } : {},
4945
+ ...failure.message !== void 0 ? { message: failure.message } : {}
4946
+ });
4947
+ }
3319
4948
  return [];
3320
4949
  }
3321
4950
  }
@@ -3334,11 +4963,36 @@ var ObjectStackAdapter = class {
3334
4963
  }
3335
4964
  /**
3336
4965
  * List registered import `mapping` artifacts targeting a given object
3337
- * (framework #2611). Reads the `mapping` metadata kind via the overlay API
4966
+ * (objectstack-ai/objectstack#2611). Reads the `mapping` metadata kind via the overlay API
3338
4967
  * and filters by `targetObject` client-side (the metadata index is
3339
- * name-only). Feeds the import wizard's "saved mapping" selector; a failure
3340
- * (older server without the `mapping` kind) degrades to an empty list, so
3341
- * the selector simply doesn't appear.
4968
+ * name-only). Feeds the import wizard's "saved mapping" selector.
4969
+ *
4970
+ * ## Every failure still degrades to an empty list — and now says which kind
4971
+ * ## of failure it was (objectui#7741)
4972
+ *
4973
+ * The degrade is unchanged and deliberate: an older server without the
4974
+ * `mapping` kind must not break the wizard, so it keeps answering `[]` and
4975
+ * the selector simply doesn't appear. That is a real, supported deployment
4976
+ * shape and it stays quiet.
4977
+ *
4978
+ * What changed is that the OTHER failures no longer render as that one. A
4979
+ * refused door and a broken one used to be indistinguishable, in the UI, from
4980
+ * "no mapping is registered" — both were an empty list behind a
4981
+ * `console.warn` nothing pointed at — and that silence produced a confident
4982
+ * wrong diagnosis in a careful reporter (objectstack#14026 was filed, routed
4983
+ * and worked by two seats against a wizard that had been correct since
4984
+ * 17.1.0). So the `catch` now asks {@link classifyImportMappingsFailure} what
4985
+ * the failure WAS, reading the error's own ADR-0112 `code` and status —
4986
+ * never "is the result empty", which is what both conditions produce and so
4987
+ * can never tell them apart — and anything that is not the supported
4988
+ * kind-absent shape is announced on {@link onMetadataReadWarning}.
4989
+ *
4990
+ * ⛔ The RETURN is untouched. This method has answered `Promise<any[]>`, never
4991
+ * throwing, since `@object-ui/data-objectstack@17.1.0`; a consumer that reads
4992
+ * nothing new sees exactly what it saw before, including on the loud arms.
4993
+ * Applying framework objectstack#13906 decision 1 option A — *a thing that could not be
4994
+ * READ is not a thing that is ABSENT* — is done by ADDING a channel, not by
4995
+ * moving that contract.
3342
4996
  */
3343
4997
  async listImportMappings(objectName) {
3344
4998
  await this.connect();
@@ -3348,6 +5002,18 @@ var ObjectStackAdapter = class {
3348
5002
  return items.filter((m) => m && m.targetObject === objectName);
3349
5003
  } catch (err) {
3350
5004
  console.warn("[OBJECTSTACKDataSource] listImportMappings failed:", err);
5005
+ const failure = classifyImportMappingsFailure(err);
5006
+ if (failure.kind !== "not-served") {
5007
+ this.emitMetadataReadWarning({
5008
+ operation: "listImportMappings",
5009
+ kind: "mapping",
5010
+ objectName,
5011
+ reason: failure.kind,
5012
+ ...failure.code !== void 0 ? { code: failure.code } : {},
5013
+ ...failure.status !== void 0 ? { status: failure.status } : {},
5014
+ ...failure.message !== void 0 ? { message: failure.message } : {}
5015
+ });
5016
+ }
3351
5017
  return [];
3352
5018
  }
3353
5019
  }
@@ -3523,6 +5189,10 @@ var ObjectStackAdapter = class {
3523
5189
  * the PUBLISHED half after the draft was discarded carries the partial
3524
5190
  * state on the error's `outcome` — "draft gone, overlay left" is exactly
3525
5191
  * what the old `{ deleted: boolean }` could not express.
5192
+ * Its message is the adapter's "NOT fully removed" sentence followed, on
5193
+ * the next line, by the server's own reason (objectui#12416), so
5194
+ * `formatMetadataError(err)` shows the remedy a refusal names; the
5195
+ * original error stays on `cause`.
3526
5196
  *
3527
5197
  * Invalidates through {@link invalidateViewKeys}: the deleted row leaves the
3528
5198
  * batch override map too, and a ghost entry there is what the object page
@@ -3548,9 +5218,12 @@ var ObjectStackAdapter = class {
3548
5218
  draft,
3549
5219
  published: { removed: false }
3550
5220
  };
5221
+ const status = err?.status;
5222
+ const refused = typeof status === "number" && status >= 400 && status < 500;
3551
5223
  throw Object.assign(
3552
5224
  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.`
5225
+ `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${refused ? "." : "; retry the delete."}
5226
+ ` + formatMetadataError(err)
3554
5227
  ),
3555
5228
  { cause: err, outcome }
3556
5229
  );
@@ -3602,11 +5275,17 @@ var ObjectStackAdapter = class {
3602
5275
  *
3603
5276
  * The maintainer ruling (2026-08-12) put the answer on the BY-NAME route
3604
5277
  * 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
5278
+ * a by-name probe already implies (objectstack#8013 / PR objectstack-ai/objectstack#8135): an app that
3606
5279
  * exists and whose `requiredPermissions` the session lacks answers `403` with
3607
5280
  * `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`.
5281
+ * name, an unpublished app, an app gated by an absent optional service — is
5282
+ * one undifferentiated answer.
5283
+ *
5284
+ * ⚠️ That absence is NOT uniformly a `404`, which this docstring asserted
5285
+ * until objectui#9262 measured it: the 404 covers an app that EXISTS and is
5286
+ * withheld for a non-permission reason, while a name that resolves to nothing
5287
+ * answers `200` with an envelope carrying no `item`. Both are absence and both
5288
+ * read as `not_found`; see {@link metaEnvelopeCarriesItem} for the measurement.
3610
5289
  *
3611
5290
  * ## Why this is a separate method and not a flavour of {@link getApp}
3612
5291
  *
@@ -3617,19 +5296,68 @@ var ObjectStackAdapter = class {
3617
5296
  * be cached beside a document about the APP — one grant, and a cached
3618
5297
  * denial outlives the session it described.
3619
5298
  *
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).
5299
+ * Nothing here throws: every outcome is a verdict the caller renders a screen
5300
+ * from, never a catch block. Only the measured `code` produces `denied` —
5301
+ * never a status, never a message (objectui#4408).
5302
+ *
5303
+ * ## objectui#9262 — what this method stopped folding away
5304
+ *
5305
+ * It used to answer `granted` whenever the call did not throw, and `unknown`
5306
+ * for everything that did. Both halves were wrong in the same direction, and
5307
+ * the first was measured on a real server before this change landed:
5308
+ *
5309
+ * - **an app that does not exist answers `200` with the envelope minus its
5310
+ * `item`**, so "did not throw" reported `granted` for a name with nothing
5311
+ * behind it — the shape issue objectui#9262 listed as cause 8 and expected
5312
+ * to be structural. It is the ordinary case: every typo, every never-created
5313
+ * app and every unpublished draft took it. See
5314
+ * {@link metaEnvelopeCarriesItem} for the measurement and the server-side
5315
+ * mechanism.
5316
+ * - a 404 absence and an unreachable server were one `unknown`, though the
5317
+ * transport had already told them apart.
5318
+ *
5319
+ * The verdicts now say only what was obtained: a served document, a refusal
5320
+ * naming permission, an answer that there is no such app here, or no answer
5321
+ * at all. `unknown` is left for the one case where nothing was asked.
3623
5322
  *
3624
5323
  * @param appName - the app name as it appears in the URL segment
3625
5324
  */
3626
5325
  async probeAppAccess(appName) {
3627
- if (!appName) return "unknown";
5326
+ return (await this.probeMetaItemAccess("app", appName)).verdict;
5327
+ }
5328
+ /**
5329
+ * {@link probeAppAccess} for any metadata type whose by-name read refuses a
5330
+ * caller (objectui#12112): why `name` is not in this session's list of
5331
+ * `type`, plus the server's own sentence when it refused.
5332
+ *
5333
+ * Since objectstack#22731 a dashboard and a list view carry the app's gate:
5334
+ * `GET /api/v1/meta/dashboard` and `/meta/view` list only what the caller may
5335
+ * open, so a withheld item and a missing one are the same absence there, and
5336
+ * the by-name read of one the caller does not hold answers `403`
5337
+ * `PERMISSION_DENIED`, "the app arm's whole refusal (same emitter, same
5338
+ * envelope)". The verdicts are therefore read exactly as for an app.
5339
+ *
5340
+ * `message` is the refusal's `message`, as the server wrote it: the one
5341
+ * sentence that names the item and says it was refused. Only on `denied`.
5342
+ *
5343
+ * Nothing here throws, and nothing is cached: a verdict about the CALLER
5344
+ * must not outlive a grant.
5345
+ *
5346
+ * @param type - the singular metadata type (`app`, `dashboard`, `view`)
5347
+ * @param name - the item's name, as the by-name route addresses it
5348
+ */
5349
+ async probeMetaItemAccess(type, name) {
5350
+ if (!type || !name) return { verdict: "unknown" };
3628
5351
  try {
3629
- await this.client.meta.getItem("app", appName);
3630
- return "granted";
5352
+ const envelope = await this.client.meta.getItem(type, name);
5353
+ return { verdict: metaEnvelopeCarriesItem(envelope) ? "granted" : "not_found" };
3631
5354
  } catch (err) {
3632
- return isAppPermissionDeniedError(err) ? "denied" : "unknown";
5355
+ if (isAppPermissionDeniedError(err)) {
5356
+ const message = err?.message;
5357
+ return typeof message === "string" && message.trim() ? { verdict: "denied", message } : { verdict: "denied" };
5358
+ }
5359
+ if (isMetaItemAbsentError(err)) return { verdict: "not_found" };
5360
+ return { verdict: "unreachable" };
3633
5361
  }
3634
5362
  }
3635
5363
  /**
@@ -3679,7 +5407,8 @@ var ObjectStackAdapter = class {
3679
5407
  * Uses `this.client.analytics.query()` from @objectstack/client to leverage
3680
5408
  * the SDK's built-in auth, headers, and fetch configuration.
3681
5409
  * Falls back to client-side aggregation via find() if the analytics endpoint
3682
- * is not available.
5410
+ * is not available. Throws {@link AnalyticsResultShapeError} when it answers
5411
+ * with anything but an `AnalyticsResult` carrying `rows`.
3683
5412
  */
3684
5413
  async aggregate(resource, params) {
3685
5414
  await this.connect();
@@ -3688,7 +5417,11 @@ var ObjectStackAdapter = class {
3688
5417
  const queryAst = {};
3689
5418
  if (Array.isArray(params.groupBy)) queryAst.groupBy = params.groupBy;
3690
5419
  if (Array.isArray(params.aggregations)) queryAst.aggregations = params.aggregations;
3691
- if (params.where !== void 0) queryAst.where = params.where;
5420
+ if (params.where !== void 0) {
5421
+ assertSpecShapeWhereIsFilterAst(params.where, resource);
5422
+ queryAst.where = params.where;
5423
+ }
5424
+ assertNoAnalyticsKeysOnSpecShape(params, resource);
3692
5425
  if (typeof params.limit === "number") queryAst.limit = params.limit;
3693
5426
  const result = await this.client.data.query(resource, queryAst);
3694
5427
  if (Array.isArray(result)) return result;
@@ -3696,6 +5429,7 @@ var ObjectStackAdapter = class {
3696
5429
  if (Array.isArray(result?.data)) return result.data;
3697
5430
  return [];
3698
5431
  }
5432
+ const analyticsWhere = params.filter ? lowerAnalyticsFilterForWire(params.filter, resource) : void 0;
3699
5433
  try {
3700
5434
  const measureName = params.function === "count" ? "count" : `${params.field}_${params.function}`;
3701
5435
  const valueKey = this.aggregateValueKey(params);
@@ -3705,17 +5439,15 @@ var ObjectStackAdapter = class {
3705
5439
  // When groupBy is '_all' no dimensions are needed (single-bucket).
3706
5440
  dimensions: params.groupBy && params.groupBy !== "_all" ? [params.groupBy] : []
3707
5441
  };
3708
- if (params.filter) {
3709
- payload.where = params.filter;
5442
+ if (analyticsWhere !== void 0) payload.where = analyticsWhere;
5443
+ const contractResult = await this.client.analytics.query(payload);
5444
+ if (!Array.isArray(contractResult?.rows)) {
5445
+ throw new AnalyticsResultShapeError(resource, contractResult);
3710
5446
  }
3711
- const data = await this.client.analytics.query(payload);
3712
- const rawRows = Array.isArray(data) ? data : data?.rows && Array.isArray(data.rows) ? data.rows : data?.data && Array.isArray(data.data) ? data.data : data?.data?.rows && Array.isArray(data.data.rows) ? data.data.rows : data?.results && Array.isArray(data.results) ? data.results : [];
3713
- const measureMissing = rawRows.length > 0 && rawRows.every((row) => {
3714
- if (row == null) return true;
3715
- if (measureName in row && row[measureName] != null) return false;
3716
- if (valueKey in row && row[valueKey] != null) return false;
3717
- return true;
3718
- });
5447
+ const rawRows = contractResult.rows;
5448
+ const measureMissing = rawRows.length > 0 && rawRows.every(
5449
+ (row) => row == null || !(measureName in row) && !(valueKey in row)
5450
+ );
3719
5451
  if (measureMissing) {
3720
5452
  return await this.aggregateViaFind(resource, params);
3721
5453
  }
@@ -3728,6 +5460,7 @@ var ObjectStackAdapter = class {
3728
5460
  return mapped;
3729
5461
  });
3730
5462
  } catch (e) {
5463
+ if (e instanceof AnalyticsResultShapeError) throw e;
3731
5464
  const failure = classifyAnalyticsFailure(e);
3732
5465
  if (failure.kind === "rejected") {
3733
5466
  throw new AnalyticsQueryRejectedError(failure.message, failure.code);
@@ -3747,6 +5480,34 @@ var ObjectStackAdapter = class {
3747
5480
  return await this.aggregateViaFind(resource, params);
3748
5481
  }
3749
5482
  }
5483
+ /**
5484
+ * Answer a grouped list view's GROUP HEADER query (objectui#7189) — see
5485
+ * `DataSource.queryGroupHeaders` in `@object-ui/types` for the contract.
5486
+ *
5487
+ * The compiled query rides the platform's EXISTING data door verbatim:
5488
+ * `client.data.query()` posts it to `POST /data/:object/query`, whose
5489
+ * `findData` routes a body carrying `groupBy` / `aggregations` to
5490
+ * `engine.aggregate` and answers the header rows as `records` (the door the
5491
+ * spec half of objectstack#14556 names, pinned platform-side by
5492
+ * objectstack#15330). No new route, no new wire shape.
5493
+ *
5494
+ * ⛔ No fallback, deliberately — unlike {@link aggregate}. A header query that
5495
+ * degraded to bucketing a fetched page would answer page slices under a name
5496
+ * that promises the query's own numbers, which is the defect this member
5497
+ * exists to end. A failure is thrown to the caller, and a body without a
5498
+ * `records` array is refused rather than read as "no groups".
5499
+ */
5500
+ async queryGroupHeaders(resource, query) {
5501
+ await this.connect();
5502
+ const result = await this.client.data.query(resource, query);
5503
+ const records = result?.records;
5504
+ if (!Array.isArray(records)) {
5505
+ throw new Error(
5506
+ `queryGroupHeaders('${resource}'): POST /data/${resource}/query answered without a \`records\` array, so no group header can be read from it. The header query was sent verbatim; nothing was approximated from a page of rows.`
5507
+ );
5508
+ }
5509
+ return records;
5510
+ }
3750
5511
  /**
3751
5512
  * Client-side aggregation over a server-scoped `find()` — the fallback used
3752
5513
  * whenever the analytics endpoint cannot answer (capability absent, network
@@ -3842,6 +5603,13 @@ var ObjectStackAdapter = class {
3842
5603
  if (errorCodeIs({ code: serverCode }, "UNAUTHENTICATED")) {
3843
5604
  throw new AnalyticsUnauthenticatedError({ serverCode, serverMessage });
3844
5605
  }
5606
+ if (errorCodeIs({ code: serverCode }, "PERMISSION_DENIED")) {
5607
+ throw new AnalyticsForbiddenError({
5608
+ serverCode,
5609
+ serverMessage,
5610
+ datasetName: requestedDatasetName
5611
+ });
5612
+ }
3845
5613
  if (serverCode === void 0) {
3846
5614
  if (ANALYTICS_ABSENT_STATUSES.includes(res.status)) {
3847
5615
  throw new AnalyticsNotInstalledError(surface, detail || void 0);
@@ -3982,9 +5750,10 @@ var ObjectStackAdapter = class {
3982
5750
  }
3983
5751
  });
3984
5752
  if (!response.ok) {
3985
- const error = await response.json().catch(() => ({ message: response.statusText }));
5753
+ const errorBody = await response.json().catch(() => ({ message: response.statusText }));
5754
+ const envelope = readErrorEnvelope(errorBody);
3986
5755
  throw new ObjectStackError(
3987
- error.message || `Upload failed with status ${response.status}`,
5756
+ envelope.message ?? `Upload failed with status ${response.status}`,
3988
5757
  "UPLOAD_ERROR",
3989
5758
  response.status
3990
5759
  );
@@ -4024,9 +5793,10 @@ var ObjectStackAdapter = class {
4024
5793
  }
4025
5794
  });
4026
5795
  if (!response.ok) {
4027
- const error = await response.json().catch(() => ({ message: response.statusText }));
5796
+ const errorBody = await response.json().catch(() => ({ message: response.statusText }));
5797
+ const envelope = readErrorEnvelope(errorBody);
4028
5798
  throw new ObjectStackError(
4029
- error.message || `Upload failed with status ${response.status}`,
5799
+ envelope.message ?? `Upload failed with status ${response.status}`,
4030
5800
  "UPLOAD_ERROR",
4031
5801
  response.status
4032
5802
  );
@@ -4108,29 +5878,50 @@ export {
4108
5878
  API_ACCESS_DENIED_CODES,
4109
5879
  APP_PERMISSION_DENIED_CODE,
4110
5880
  AnalyticsDatasetNotFoundError,
5881
+ AnalyticsForbiddenError,
5882
+ AnalyticsKeysOnSpecShapeError,
4111
5883
  AnalyticsNotInstalledError,
4112
5884
  AnalyticsQueryRejectedError,
5885
+ AnalyticsResultShapeError,
4113
5886
  AnalyticsUnauthenticatedError,
4114
5887
  AuthenticationError,
4115
5888
  BulkOperationError,
5889
+ CHOICE_TYPES_REQUIRING_OPTIONS,
4116
5890
  ConcurrentUpdateError,
4117
5891
  ConnectionError,
4118
5892
  DataApiValidationError,
4119
5893
  FILTER_OPERATOR_ALIASES,
5894
+ META_ITEM_ABSENT_CODE,
4120
5895
  MalformedFilterError,
4121
5896
  MetadataClient,
4122
5897
  MetadataNotFoundError,
5898
+ OBJECT_METADATA_TYPE,
4123
5899
  ObjectStackAdapter,
4124
5900
  ObjectStackError,
5901
+ PREFERENCE_FAMILY_ROW_LIMIT,
5902
+ RELATIONSHIP_TYPES_REQUIRING_REFERENCE,
5903
+ UNRECOGNIZED_DROP_REASON,
5904
+ UnlowerableAnalyticsFilterError,
5905
+ UnloweredAggregateWhereError,
4125
5906
  VIEW_OVERLAY_OWNED_KEYS,
5907
+ assertObjectMetadataWritable,
4126
5908
  classifyAnalyticsFailure,
5909
+ classifyImportMappingsFailure,
5910
+ classifyViewsFailure,
4127
5911
  clearSharedDiscoveryCache,
4128
5912
  createErrorFromResponse,
4129
5913
  createObjectStackAdapter,
5914
+ createObjectStackUserPreferenceFamily,
4130
5915
  createObjectStackUserStateAdapter,
4131
5916
  createQuietHttpLogger,
5917
+ draftChangesUnder,
5918
+ dropServedPicklistOptions,
5919
+ extractDraftBody,
4132
5920
  formatHttpFailureMessage,
5921
+ formatMetadataError,
5922
+ formatMetadataIssue,
4133
5923
  getSharedDiscovery,
5924
+ governingDraftChange,
4134
5925
  is404Error,
4135
5926
  isAnalyticsNotInstalledError,
4136
5927
  isApiAccessDeniedError,
@@ -4138,9 +5929,11 @@ export {
4138
5929
  isConcurrentUpdateError,
4139
5930
  isErrorType,
4140
5931
  isMalformedFilterError,
5932
+ isMetaItemAbsentError,
4141
5933
  isObjectStackError,
4142
5934
  narrowPersonalizationOverlay,
4143
5935
  normaliseClientError,
5936
+ readDraftChanges,
4144
5937
  readSaveAdvisories,
4145
5938
  readTransactionalBatchCapability,
4146
5939
  serializeOrderBy,