@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.cjs CHANGED
@@ -25,29 +25,50 @@ __export(index_exports, {
25
25
  API_ACCESS_DENIED_CODES: () => API_ACCESS_DENIED_CODES,
26
26
  APP_PERMISSION_DENIED_CODE: () => APP_PERMISSION_DENIED_CODE,
27
27
  AnalyticsDatasetNotFoundError: () => AnalyticsDatasetNotFoundError,
28
+ AnalyticsForbiddenError: () => AnalyticsForbiddenError,
29
+ AnalyticsKeysOnSpecShapeError: () => AnalyticsKeysOnSpecShapeError,
28
30
  AnalyticsNotInstalledError: () => AnalyticsNotInstalledError,
29
31
  AnalyticsQueryRejectedError: () => AnalyticsQueryRejectedError,
32
+ AnalyticsResultShapeError: () => AnalyticsResultShapeError,
30
33
  AnalyticsUnauthenticatedError: () => AnalyticsUnauthenticatedError,
31
34
  AuthenticationError: () => AuthenticationError,
32
35
  BulkOperationError: () => BulkOperationError,
36
+ CHOICE_TYPES_REQUIRING_OPTIONS: () => CHOICE_TYPES_REQUIRING_OPTIONS,
33
37
  ConcurrentUpdateError: () => ConcurrentUpdateError,
34
38
  ConnectionError: () => ConnectionError,
35
39
  DataApiValidationError: () => DataApiValidationError,
36
40
  FILTER_OPERATOR_ALIASES: () => FILTER_OPERATOR_ALIASES,
41
+ META_ITEM_ABSENT_CODE: () => META_ITEM_ABSENT_CODE,
37
42
  MalformedFilterError: () => MalformedFilterError,
38
43
  MetadataClient: () => MetadataClient,
39
44
  MetadataNotFoundError: () => MetadataNotFoundError,
45
+ OBJECT_METADATA_TYPE: () => OBJECT_METADATA_TYPE,
40
46
  ObjectStackAdapter: () => ObjectStackAdapter,
41
47
  ObjectStackError: () => ObjectStackError,
48
+ PREFERENCE_FAMILY_ROW_LIMIT: () => PREFERENCE_FAMILY_ROW_LIMIT,
49
+ RELATIONSHIP_TYPES_REQUIRING_REFERENCE: () => RELATIONSHIP_TYPES_REQUIRING_REFERENCE,
50
+ UNRECOGNIZED_DROP_REASON: () => UNRECOGNIZED_DROP_REASON,
51
+ UnlowerableAnalyticsFilterError: () => UnlowerableAnalyticsFilterError,
52
+ UnloweredAggregateWhereError: () => UnloweredAggregateWhereError,
42
53
  VIEW_OVERLAY_OWNED_KEYS: () => VIEW_OVERLAY_OWNED_KEYS,
54
+ assertObjectMetadataWritable: () => assertObjectMetadataWritable,
43
55
  classifyAnalyticsFailure: () => classifyAnalyticsFailure,
56
+ classifyImportMappingsFailure: () => classifyImportMappingsFailure,
57
+ classifyViewsFailure: () => classifyViewsFailure,
44
58
  clearSharedDiscoveryCache: () => clearSharedDiscoveryCache,
45
59
  createErrorFromResponse: () => createErrorFromResponse,
46
60
  createObjectStackAdapter: () => createObjectStackAdapter,
61
+ createObjectStackUserPreferenceFamily: () => createObjectStackUserPreferenceFamily,
47
62
  createObjectStackUserStateAdapter: () => createObjectStackUserStateAdapter,
48
63
  createQuietHttpLogger: () => createQuietHttpLogger,
64
+ draftChangesUnder: () => draftChangesUnder,
65
+ dropServedPicklistOptions: () => dropServedPicklistOptions,
66
+ extractDraftBody: () => extractDraftBody,
49
67
  formatHttpFailureMessage: () => formatHttpFailureMessage,
68
+ formatMetadataError: () => formatMetadataError,
69
+ formatMetadataIssue: () => formatMetadataIssue,
50
70
  getSharedDiscovery: () => getSharedDiscovery,
71
+ governingDraftChange: () => governingDraftChange,
51
72
  is404Error: () => is404Error,
52
73
  isAnalyticsNotInstalledError: () => isAnalyticsNotInstalledError,
53
74
  isApiAccessDeniedError: () => isApiAccessDeniedError,
@@ -55,9 +76,11 @@ __export(index_exports, {
55
76
  isConcurrentUpdateError: () => isConcurrentUpdateError,
56
77
  isErrorType: () => isErrorType,
57
78
  isMalformedFilterError: () => isMalformedFilterError,
79
+ isMetaItemAbsentError: () => isMetaItemAbsentError,
58
80
  isObjectStackError: () => isObjectStackError,
59
81
  narrowPersonalizationOverlay: () => narrowPersonalizationOverlay,
60
82
  normaliseClientError: () => normaliseClientError,
83
+ readDraftChanges: () => readDraftChanges,
61
84
  readSaveAdvisories: () => readSaveAdvisories,
62
85
  readTransactionalBatchCapability: () => readTransactionalBatchCapability,
63
86
  serializeOrderBy: () => serializeOrderBy,
@@ -65,6 +88,72 @@ __export(index_exports, {
65
88
  });
66
89
  module.exports = __toCommonJS(index_exports);
67
90
  var import_client = require("@objectstack/client");
91
+ var import_ui2 = require("@objectstack/spec/ui");
92
+ var import_data4 = require("@objectstack/spec/data");
93
+ var import_data5 = require("@objectstack/spec/data");
94
+ var import_data6 = require("@objectstack/spec/data");
95
+ var import_api3 = require("@objectstack/spec/api");
96
+ var import_kernel2 = require("@objectstack/spec/kernel");
97
+
98
+ // src/metadata-client.ts
99
+ var import_api = require("@objectstack/spec/api");
100
+
101
+ // src/object-metadata-write-guard.ts
102
+ var RELATIONSHIP_TYPES_REQUIRING_REFERENCE = ["lookup", "master_detail"];
103
+ var CHOICE_TYPES_REQUIRING_OPTIONS = ["select", "radio"];
104
+ var OBJECT_METADATA_TYPE = "object";
105
+ function isUsableTarget(reference) {
106
+ return typeof reference === "string" && reference.trim() !== "";
107
+ }
108
+ function describeTarget(reference) {
109
+ if (reference === void 0) return "no `reference` key at all";
110
+ if (reference === null) return "a `null` `reference`";
111
+ if (typeof reference !== "string") return `a \`reference\` of type \`${typeof reference}\``;
112
+ if (reference === "") return "an empty `reference`";
113
+ return "a whitespace-only `reference`";
114
+ }
115
+ function hasOptionSource(def) {
116
+ return Array.isArray(def.options) && def.options.length > 0 || typeof def.picklist === "string";
117
+ }
118
+ function describeOptions(options) {
119
+ if (options === void 0) return "no `options` key and no `picklist`";
120
+ if (Array.isArray(options)) return "an empty `options` list and no `picklist`";
121
+ if (options === null) return "a `null` `options` and no `picklist`";
122
+ return `an \`options\` value of type \`${typeof options}\` and no \`picklist\``;
123
+ }
124
+ function fieldEntries(fields) {
125
+ if (Array.isArray(fields)) {
126
+ return fields.flatMap((raw, index) => {
127
+ if (!raw || typeof raw !== "object") return [];
128
+ const record = raw;
129
+ const name = typeof record.name === "string" && record.name ? record.name : `[${index}]`;
130
+ return [{ name, def: record }];
131
+ });
132
+ }
133
+ if (fields && typeof fields === "object") {
134
+ return Object.entries(fields).flatMap(
135
+ ([name, def]) => def && typeof def === "object" ? [{ name, def }] : []
136
+ );
137
+ }
138
+ return [];
139
+ }
140
+ function assertObjectMetadataWritable(type, item, _writer) {
141
+ if (String(type) !== OBJECT_METADATA_TYPE) return;
142
+ if (!item || typeof item !== "object") return;
143
+ const fields = item.fields;
144
+ for (const { name, def } of fieldEntries(fields)) {
145
+ if (CHOICE_TYPES_REQUIRING_OPTIONS.includes(String(def.type)) && !hasOptionSource(def)) {
146
+ throw new Error(
147
+ `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.`
148
+ );
149
+ }
150
+ if (!RELATIONSHIP_TYPES_REQUIRING_REFERENCE.includes(String(def.type))) continue;
151
+ if (isUsableTarget(def.reference)) continue;
152
+ throw new Error(
153
+ `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.`
154
+ );
155
+ }
156
+ }
68
157
 
69
158
  // src/metadata-client.ts
70
159
  function readSaveAdvisories(body) {
@@ -77,6 +166,34 @@ function readSaveAdvisories(body) {
77
166
  return typeof c.rule === "string" && typeof c.path === "string" && typeof c.where === "string" && typeof c.message === "string" && typeof c.hint === "string" && typeof c.severity === "string";
78
167
  });
79
168
  }
169
+ function readRegistrations(raw) {
170
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
171
+ const out = {};
172
+ for (const [name, verdict] of Object.entries(raw)) {
173
+ const parsed = import_api.FlowRegistrationSchema.safeParse(verdict);
174
+ if (parsed.success) out[name] = parsed.data;
175
+ }
176
+ return out;
177
+ }
178
+ var PROTECTION_ENVELOPE_KEYS = [
179
+ "overlayScope",
180
+ "lock",
181
+ "lockReason",
182
+ "lockSource",
183
+ "lockDocsUrl",
184
+ "provenance",
185
+ "packageId",
186
+ "packageVersion",
187
+ "editable",
188
+ "deletable",
189
+ "resettable"
190
+ ];
191
+ function unrecognizedEnvelopeKeys(body) {
192
+ const shape = import_api.GetMetaItemLayeredResponseSchema.shape;
193
+ return PROTECTION_ENVELOPE_KEYS.filter(
194
+ (key) => body[key] !== void 0 && !shape[key].safeParse(body[key]).success
195
+ );
196
+ }
80
197
  var META_PREFIX = "/meta";
81
198
  var API_PREFIX = "/api/v1";
82
199
  function buildBase(config) {
@@ -89,6 +206,16 @@ function buildBase(config) {
89
206
  }
90
207
  return `${trimmed}${scoped}`;
91
208
  }
209
+ function packagesBaseOf(metaBase) {
210
+ const origin = metaBase.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, "");
211
+ return `${origin}${API_PREFIX}/packages`;
212
+ }
213
+ function unwrapDispatcherEnvelope(body) {
214
+ if (!body || typeof body !== "object") return {};
215
+ const root = body;
216
+ const data = root.data;
217
+ return data && typeof data === "object" && !Array.isArray(data) ? data : root;
218
+ }
92
219
  async function parseError(res) {
93
220
  let body;
94
221
  try {
@@ -114,6 +241,26 @@ function isMetaItemEnvelope(value) {
114
241
  const env = value;
115
242
  return typeof env.type === "string" && typeof env.name === "string" && "item" in env;
116
243
  }
244
+ var pendingReads = /* @__PURE__ */ new WeakMap();
245
+ function pendingReadsOn(transport) {
246
+ let reads = pendingReads.get(transport);
247
+ if (!reads) {
248
+ reads = /* @__PURE__ */ new Map();
249
+ pendingReads.set(transport, reads);
250
+ }
251
+ return reads;
252
+ }
253
+ function handOut(entry) {
254
+ entry.waiting += 1;
255
+ return entry.read.then((value) => {
256
+ entry.waiting -= 1;
257
+ return entry.waiting === 0 ? value : ownCopy(value);
258
+ });
259
+ }
260
+ function ownCopy(value) {
261
+ if (value === null || typeof value !== "object") return value;
262
+ return typeof structuredClone === "function" ? structuredClone(value) : JSON.parse(JSON.stringify(value));
263
+ }
117
264
  var MetadataClient = class _MetadataClient {
118
265
  constructor(config) {
119
266
  __publicField(this, "base");
@@ -121,7 +268,7 @@ var MetadataClient = class _MetadataClient {
121
268
  __publicField(this, "headers");
122
269
  /** ADR-0037: when true, reads render the draft-overlaid world. */
123
270
  __publicField(this, "previewDrafts");
124
- /** #4133 — sink for post-save advisory findings; see the config field. */
271
+ /** #4133 / #5026 — sink for every write door's advisory findings; see the config field. */
125
272
  __publicField(this, "onSaveAdvisory");
126
273
  this.base = buildBase(config);
127
274
  this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
@@ -164,34 +311,102 @@ var MetadataClient = class _MetadataClient {
164
311
  ...this.onSaveAdvisory ? { onSaveAdvisory: this.onSaveAdvisory } : {}
165
312
  });
166
313
  }
314
+ /**
315
+ * Share one in-flight GET among the callers asking the same method for the
316
+ * same URL with the same headers through the same transport (objectui#11797).
317
+ *
318
+ * Opening a Studio package mounts several readers that ask the same
319
+ * question at once, and each one sent its own request. The first call for a
320
+ * key sends `read()`; a call that arrives while it is pending waits for that
321
+ * request instead of sending another.
322
+ *
323
+ * ⛔ Not a response cache, deliberately — the rules `findOne` keeps on the
324
+ * data adapter (objectui#11699):
325
+ * - the entry goes the moment the read settles, so a call made after an
326
+ * answer always reaches the network (there is no reuse window: a client
327
+ * cannot see every write that changes metadata, an agent's server-side
328
+ * edits among them, and a window would answer the read after such a write
329
+ * with the state from before it);
330
+ * - a rejection reaches every caller already waiting and is not remembered;
331
+ * - a write sent through the same transport drops every pending read when
332
+ * it lands (see {@link sendWrite}), so a read asked after a save or a
333
+ * publish is never answered by a request sent before it;
334
+ * - every caller, the first one included, gets an answer no other caller
335
+ * holds (see {@link handOut}).
336
+ */
337
+ shareRead(reader, url, read) {
338
+ const reads = pendingReadsOn(this.fetchImpl);
339
+ const key = JSON.stringify([reader, url, Object.entries(this.headers).sort(([a], [b]) => a.localeCompare(b))]);
340
+ let entry = reads.get(key);
341
+ if (!entry) {
342
+ const created = { read: read(), waiting: 0 };
343
+ reads.set(key, created);
344
+ const settle = () => {
345
+ if (reads.get(key) === created) reads.delete(key);
346
+ };
347
+ created.read.then(settle, settle);
348
+ entry = created;
349
+ }
350
+ return handOut(entry);
351
+ }
352
+ /**
353
+ * Send a write, then drop every read still pending on this transport
354
+ * (objectui#11797): a read on the wire when the write lands was asked before
355
+ * it, so a caller asking after the write must not join it. Dropped however
356
+ * the write ends — a refused or interrupted write may still have changed
357
+ * something. The callers already holding a pending read keep its answer.
358
+ */
359
+ async sendWrite(url, init) {
360
+ try {
361
+ return await this.fetchImpl(url, init);
362
+ } finally {
363
+ pendingReads.get(this.fetchImpl)?.clear();
364
+ }
365
+ }
167
366
  /** List all registered metadata types (returns the registry rows). */
168
367
  async listTypes() {
169
- const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
170
- if (!res.ok) throw await parseError(res);
171
- const data = await res.json();
172
- if (Array.isArray(data)) return data;
173
- if (data && Array.isArray(data.entries)) return data.entries;
174
- if (data && Array.isArray(data.items)) return data.items;
175
- if (data && Array.isArray(data.types)) {
176
- return data.types.map((t) => ({ type: t }));
177
- }
178
- return [];
368
+ return this.shareRead("listTypes", this.base, async () => {
369
+ const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
370
+ if (!res.ok) throw await parseError(res);
371
+ const data = await res.json();
372
+ if (Array.isArray(data)) return data;
373
+ if (data && Array.isArray(data.entries)) return data.entries;
374
+ if (data && Array.isArray(data.items)) return data.items;
375
+ if (data && Array.isArray(data.types)) {
376
+ return data.types.map((t) => ({ type: t }));
377
+ }
378
+ return [];
379
+ });
179
380
  }
180
381
  /** List items of a metadata type (e.g. `object`, `field`, `view`). */
181
382
  async list(type, options = {}) {
383
+ return (await this.listResponse(type, options)).items;
384
+ }
385
+ /**
386
+ * The same list read as {@link list}, with what the server serves beside
387
+ * the items (objectui#12349): today that is the automation engine's
388
+ * `registrations` on a `flow` list — see {@link MetadataListAnswer}.
389
+ *
390
+ * One request for both methods: `list` is this read with the items picked
391
+ * out, so a caller asking both at once shares one in-flight GET.
392
+ */
393
+ async listResponse(type, options = {}) {
182
394
  const params = [];
183
395
  if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
184
396
  if (this.previewDrafts) params.push("preview=draft");
185
397
  const qs = params.length ? `?${params.join("&")}` : "";
186
398
  const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
187
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
188
- if (!res.ok) throw await parseError(res);
189
- const data = await res.json();
190
- if (Array.isArray(data)) return data;
191
- if (data && Array.isArray(data.items)) {
192
- return data.items;
193
- }
194
- return [];
399
+ return this.shareRead("list", url, async () => {
400
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
401
+ if (!res.ok) throw await parseError(res);
402
+ const data = await res.json();
403
+ if (Array.isArray(data)) return { items: data };
404
+ if (data && Array.isArray(data.items)) {
405
+ const registrations = readRegistrations(data.registrations);
406
+ return { items: data.items, ...registrations ? { registrations } : {} };
407
+ }
408
+ return { items: [] };
409
+ });
195
410
  }
196
411
  /**
197
412
  * List pending DRAFT items (ADR-0033) — what an AI authored but nobody
@@ -207,11 +422,13 @@ var MetadataClient = class _MetadataClient {
207
422
  if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
208
423
  const qs = params.length ? `?${params.join("&")}` : "";
209
424
  const url = `${this.base}/_drafts${qs}`;
210
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
211
- if (!res.ok) throw await parseError(res);
212
- const data = await res.json();
213
- if (Array.isArray(data)) return data;
214
- return data?.drafts ?? data?.data?.drafts ?? [];
425
+ return this.shareRead("listDrafts", url, async () => {
426
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
427
+ if (!res.ok) throw await parseError(res);
428
+ const data = await res.json();
429
+ if (Array.isArray(data)) return data;
430
+ return data?.drafts ?? data?.data?.drafts ?? [];
431
+ });
215
432
  }
216
433
  /**
217
434
  * Fetch a single metadata item and return the response body EXACTLY as the
@@ -230,10 +447,12 @@ var MetadataClient = class _MetadataClient {
230
447
  if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
231
448
  const qs = params.length ? `?${params.join("&")}` : "";
232
449
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
233
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
234
- if (res.status === 404) return null;
235
- if (!res.ok) throw await parseError(res);
236
- return await res.json();
450
+ return this.shareRead("readItemResponse", url, async () => {
451
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
452
+ if (res.status === 404) return null;
453
+ if (!res.ok) throw await parseError(res);
454
+ return await res.json();
455
+ });
237
456
  }
238
457
  /**
239
458
  * Get a single metadata item. Returns the unwrapped item CONTENT — the
@@ -275,15 +494,90 @@ var MetadataClient = class _MetadataClient {
275
494
  * {@link get} is deliberate and long-standing (the draft envelope's identity
276
495
  * and protection carriers are part of what a draft reader inspects), so it
277
496
  * is preserved by reading the transport directly instead of going through
278
- * `get()`'s unwrap. `unwrapDraftBody` (app-shell) and `unwrapViewDraft`
279
- * (this package) are the shared helpers for taking the body out.
497
+ * `get()`'s unwrap.
498
+ *
499
+ * ⛔ Do NOT hand `.item` to a gate or a write yourself: the body this serves
500
+ * is DECORATED (`_draft`, then `_diagnostics` from `decorateMetadataItem`),
501
+ * and the spec says a served body is not a valid input to the schema that
502
+ * produced it until those come off. {@link extractDraftBody} is the one
503
+ * reader for this method's answer — it takes the presence verdict and then
504
+ * the strip, in that order. That function used to be copy-pasted into four
505
+ * views and only one copy knew the rule (objectui#8181).
506
+ *
507
+ * Its tolerant siblings exist for the OTHER client method: `unwrapDraftBody`
508
+ * (app-shell) and `unwrapViewDraft` (this package) read a draft that arrived
509
+ * through {@link get}, which already unwrapped the envelope. They split on
510
+ * which method feeds them, not on what they mean — all three strip.
511
+ *
512
+ * objectui#11799 — whether a draft exists is answered by the pending-drafts
513
+ * ledger first (see {@link draftLedgerMayHold}), and `?state=draft` is sent
514
+ * only when the ledger lists the name. The item read answers 404 when there
515
+ * is no draft, so opening every item in Studio used to log one error per
516
+ * item for an answer the ledger gives with a 200.
280
517
  */
281
518
  async getDraft(type, name, options = {}) {
519
+ if (!await this.draftLedgerMayHold(name)) return null;
282
520
  return this.readItemResponse(type, name, {
283
521
  state: "draft",
284
522
  ...options.packageId ? { packageId: options.packageId } : {}
285
523
  });
286
524
  }
525
+ /**
526
+ * Whether the pending-drafts ledger (`GET /meta/_drafts`) may hold a draft
527
+ * named `name` (objectui#11799).
528
+ *
529
+ * Read through {@link listDrafts}, the shared read objectui#11797 made: a
530
+ * question asked while a ledger read is pending waits for it, and a write
531
+ * through this transport drops it when the write lands ({@link sendWrite}),
532
+ * so a draft saved a moment ago is never reported absent by a read sent
533
+ * before the save.
534
+ *
535
+ * The WHOLE ledger, matched by name. The `?state=draft` read finds a row
536
+ * under either spelling of the type, and, when it names a package, that
537
+ * package's row or the package-less one. The ledger's `type` and `packageId`
538
+ * filters are exact, so a filtered ledger could miss a draft the item read
539
+ * would serve, and the editor would open the published body over a pending
540
+ * draft. Unfiltered, every miss here is a real absence; a same-name row of
541
+ * another type or package only costs the item read that was always sent.
542
+ *
543
+ * A ledger that cannot be read (403 without an authoring capability, 501 on
544
+ * a kernel without drafts, a network fault) is UNKNOWN, not empty: the item
545
+ * read is sent, as before.
546
+ */
547
+ async draftLedgerMayHold(name) {
548
+ let drafts;
549
+ try {
550
+ drafts = await this.listDrafts();
551
+ } catch {
552
+ return true;
553
+ }
554
+ return drafts.some((d) => d?.name === name);
555
+ }
556
+ /**
557
+ * Report the runtime authoring gate's advisory findings for a write the
558
+ * server has ALREADY committed (#4133 for the save door, #5026 for the
559
+ * publish door).
560
+ *
561
+ * Everything here is best-effort by construction, and that is the whole
562
+ * contract: the row is committed server-side before this runs, so neither a
563
+ * malformed body, nor a missing key, nor a throwing sink may change what the
564
+ * calling method returns or whether it throws. The server emits `advisories`
565
+ * ONLY when non-empty, so a clean write costs one absent-key check.
566
+ *
567
+ * One helper rather than a copy per door: every door's response declares the
568
+ * key identically, so a second inline copy could only ever drift. The batch
569
+ * door (`ce986aafc`) calls it once per `published[]` element, handing it
570
+ * the element — the object that carries the key there — and the same
571
+ * best-effort contract covers that loop unchanged.
572
+ */
573
+ emitAdvisories(body, event) {
574
+ if (!this.onSaveAdvisory) return;
575
+ try {
576
+ const advisories = readSaveAdvisories(body);
577
+ if (advisories.length > 0) this.onSaveAdvisory({ ...event, advisories });
578
+ } catch {
579
+ }
580
+ }
287
581
  /**
288
582
  * Save (PUT) a metadata item. The framework accepts both the bare
289
583
  * item payload and the `{ item: ... }` / `{ metadata: ... }`
@@ -296,10 +590,17 @@ var MetadataClient = class _MetadataClient {
296
590
  `MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
297
591
  );
298
592
  }
593
+ if (options.ifMatch && options.ifNoneMatch) {
594
+ throw new Error(
595
+ `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.`
596
+ );
597
+ }
598
+ assertObjectMetadataWritable(type, item, "MetadataClient.save");
299
599
  const params = [];
300
600
  if (options.force) params.push("force=true");
301
601
  if (options.mode === "draft") params.push("mode=draft");
302
602
  if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
603
+ if (options.surface) params.push(`surface=${encodeURIComponent(options.surface)}`);
303
604
  const qs = params.length ? `?${params.join("&")}` : "";
304
605
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
305
606
  const headers = {
@@ -307,27 +608,20 @@ var MetadataClient = class _MetadataClient {
307
608
  "Content-Type": "application/json"
308
609
  };
309
610
  if (options.ifMatch) headers["If-Match"] = options.ifMatch;
310
- const res = await this.fetchImpl(url, {
611
+ if (options.ifNoneMatch) headers["If-None-Match"] = options.ifNoneMatch;
612
+ const res = await this.sendWrite(url, {
311
613
  method: "PUT",
312
614
  headers,
313
615
  body: JSON.stringify(item)
314
616
  });
315
617
  if (!res.ok) throw await parseError(res);
316
618
  const body = await res.json();
317
- if (this.onSaveAdvisory) {
318
- try {
319
- const advisories = readSaveAdvisories(body);
320
- if (advisories.length > 0) {
321
- this.onSaveAdvisory({
322
- type,
323
- name,
324
- mode: options.mode === "draft" ? "draft" : "publish",
325
- advisories
326
- });
327
- }
328
- } catch {
329
- }
330
- }
619
+ this.emitAdvisories(body, {
620
+ type,
621
+ name,
622
+ door: "save",
623
+ mode: options.mode === "draft" ? "draft" : "publish"
624
+ });
331
625
  return body;
332
626
  }
333
627
  /**
@@ -341,18 +635,178 @@ var MetadataClient = class _MetadataClient {
341
635
  * the rows and reports under `seedApplied` — a data problem never fails the
342
636
  * publish, so callers should check `seedApplied?.success` and warn the user
343
637
  * rather than assume the data went live.
638
+ *
639
+ * Same door as {@link publish} (`POST /meta/:type/:name/publish`), so it
640
+ * reports the gate's advisories the same way (#5026).
641
+ *
642
+ * The BATCH door (`POST /packages/:id/publish-drafts`) is a different route
643
+ * with a method of its own — {@link publishPackageDrafts} — and it reports
644
+ * too. It did not when this paragraph was first written: it discarded
645
+ * per-draft advisories server-side, which was objectstack#9343, and that card
646
+ * has since landed with a ruling that each `published[]` element carries
647
+ * them (`ce986aafc` is the client half).
648
+ *
649
+ * What survives that change is the READING RULE, which is about this method
650
+ * rather than about the other route: it reads the top level of the
651
+ * single-item body and nothing else. A batch-shaped body arriving HERE still
652
+ * reports nothing, because `PublishMetaItemResponse` declares no
653
+ * `published[]` and a client that went looking for findings wherever they
654
+ * might be would be inventing a contract instead of reading one.
655
+ *
656
+ * ## Why there is no `{ success, data }` unwrapping here (`7b433197d`)
657
+ *
658
+ * This method used to unwrap a dispatcher-shaped envelope before returning,
659
+ * while {@link publish} — the same route, a couple of hundred lines down —
660
+ * did not. Two spellings of one wire contract, and nothing said which was
661
+ * right. The tolerance is gone because the route was MEASURED, at the
662
+ * producer rather than inferred from the response schema:
663
+ *
664
+ * - The only mount of `POST /api/v1/meta/:type/:name/publish` is the REST
665
+ * server's own route-manager registration, which answers
666
+ * `res.json(await protocol.publishMetaItem(...))` — the protocol's object
667
+ * verbatim, with no envelope branch. The project-scoped mirror
668
+ * (`/api/v1/environments/:environmentId/...`) re-mounts that same handler.
669
+ * - The `{ success, data }` envelope has exactly one producer, the runtime
670
+ * `HttpDispatcher`, and it does not serve this route: its `/meta` domain
671
+ * has no publish branch, the dispatcher's own route ledger has no row for
672
+ * it, and a three-segment `/meta` path that is not `/published` terminates
673
+ * in a located `routeNotFound`. So no composition — REST server, the
674
+ * dispatcher-only Hono adapter, or both — puts an envelope on this body.
675
+ * - `packages/spec` states the same split in the two docstrings side by
676
+ * side: `PublishMetaItemResponseSchema` describes "the FULL body" this
677
+ * route returns and records that the REST route hands the producer's
678
+ * object to `res.json()` verbatim, while its batch sibling
679
+ * `PublishPackageDraftsResponseSchema` describes a body answered
680
+ * explicitly "inside the dispatcher's `{ success, data }` envelope".
681
+ *
682
+ * ⛔ So an enveloped body on THIS route is not a shape the platform emits,
683
+ * and unwrapping one is not a kindness: it is Commandment #0.1's lenient
684
+ * fallback, a second de-facto contract for a route that declares one. It
685
+ * also fails in the direction that hides the problem — a body that arrived
686
+ * enveloped is one this door did not actually serve, and unwrapping it
687
+ * would present that as a successful promotion.
688
+ *
689
+ * ⚠️ Sibling tolerances in this file are NOT this defect and must stay:
690
+ * {@link listDrafts} reads `data?.data?.drafts` because `GET /meta/_drafts`
691
+ * IS a dispatcher route, and `publishHealthFromResponse` unwraps because the
692
+ * batch door really is enveloped. The rule is per-route, not per-file.
693
+ *
694
+ * ## The bundle member (objectui#12402)
695
+ *
696
+ * Pass `member` to publish one member of a type whose identity is a pair
697
+ * (today only `email_template`, `(name, locale)`), sent as `?member=` — the
698
+ * selector the by-name doors read since objectstack `038d4c958`, and the
699
+ * value a `GET /meta/_drafts` row carries as `member`. A publish that names
700
+ * none promotes the CANONICAL member's draft, so two languages of one
701
+ * template drafted outside a package cannot both be published by name
702
+ * without it. The parameter is OMITTED, never sent empty, when the caller
703
+ * has no member: the door refuses a blank selector, and refuses any
704
+ * selector on a type with no members.
344
705
  */
345
- async publishDraft(type, name) {
346
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
347
- const res = await this.fetchImpl(url, {
706
+ async publishDraft(type, name, options = {}) {
707
+ const params = [];
708
+ if (options.member) params.push(`member=${encodeURIComponent(options.member)}`);
709
+ const qs = params.length ? `?${params.join("&")}` : "";
710
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish${qs}`;
711
+ const res = await this.sendWrite(url, {
348
712
  method: "POST",
349
713
  headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
350
714
  body: "{}"
351
715
  });
352
716
  if (!res.ok) throw await parseError(res);
353
717
  const body = await res.json().catch(() => ({}));
354
- const inner = body?.data && typeof body.data === "object" ? body.data : body;
355
- return inner;
718
+ this.emitAdvisories(body, {
719
+ type,
720
+ name,
721
+ // objectui#12413 — the member this publish named, under the same rule as
722
+ // the query parameter above: present only when the caller had one.
723
+ ...options.member ? { member: options.member } : {},
724
+ door: "publish",
725
+ mode: "publish"
726
+ });
727
+ return body;
728
+ }
729
+ /**
730
+ * Publish EVERY pending draft bound to one package — Studio's "publish whole
731
+ * app" door, `POST /api/v1/packages/:id/publish-drafts` (ADR-0033; the route
732
+ * that orders structure-before-seeds server-side and runs the ADR-0038 L3
733
+ * runtime probes).
734
+ *
735
+ * ## Why it is expressed here at all (`ce986aafc`)
736
+ *
737
+ * It was not, and that was the defect. Two app-shell call sites fired this
738
+ * route with a bare `fetch` / a page-private `apiJson`, outside the seam that
739
+ * covers every other metadata write — so when objectstack#9343 landed and the
740
+ * server began sending per-draft advisories, the author publishing a whole
741
+ * app was told nothing, while the SAME button's client-side capability lint
742
+ * still raised a toast. A door that cannot report is not a door the gate can
743
+ * reach, however loudly the server speaks.
744
+ *
745
+ * ## What it reports, and what it refuses to invent
746
+ *
747
+ * `PublishPackageDraftsResponseSchema` declares `advisories` on EACH
748
+ * `published[]` element — objectstack#9343's ruled shape, the same element
749
+ * type and the same omitted-when-empty discipline as the single-item door,
750
+ * and explicitly not a parallel top-level map. So this method emits one
751
+ * {@link MetadataSaveAdvisoryEvent} per advised element, each naming that
752
+ * element's own `type` / `name`, through the same sink, event and renderer
753
+ * the save and single-item publish doors use.
754
+ *
755
+ * ⛔ It renders only what the server sent where the schema says it sits:
756
+ *
757
+ * - `advisories` is read off the element, by the one shared reader, which
758
+ * drops anything that is not a complete finding.
759
+ * - An element that does not carry the `type` and `name` the schema states as
760
+ * REQUIRED is skipped rather than reported under invented identity — the
761
+ * event names the item the author has to go fix, and an event that cannot
762
+ * name it truthfully is worse than silence.
763
+ * - Nothing is derived, counted or summarised from the batch: no advisory
764
+ * exists here that the server did not put in the body.
765
+ *
766
+ * ## The failure shapes stay the callers'
767
+ *
768
+ * Non-2xx throws {@link MetadataError}, like every other method. A 2xx is
769
+ * RETURNED unexamined — `success: false` is not a failure on this route
770
+ * (`outcome: 'nothing_to_publish'` answers it too, and the spec's own text
771
+ * says to read `outcome`, not the boolean), and the two callers have their
772
+ * own, different rules for the refusal and rolled-back cases. Deciding that
773
+ * here would change behaviour this card is not about.
774
+ *
775
+ * ## The opt-in backfill (objectui#12929)
776
+ *
777
+ * `options.backfill` is sent as the body's `backfill` key, as given, and only
778
+ * when the caller passes one ({@link MetadataPublishPackageDraftsOptions}).
779
+ * Without it the body is `{}`, byte for byte what every caller sent before,
780
+ * so a caller that names nothing writes nothing into existing rows. The
781
+ * receipt is `backfillApplied` on the returned body, unexamined like the rest
782
+ * of a 2xx: one element per named field with the rows written, and `error`
783
+ * on a field that stopped short (the publish itself stands).
784
+ */
785
+ async publishPackageDrafts(packageId, options = {}) {
786
+ if (!packageId || !String(packageId).trim()) {
787
+ throw new Error(
788
+ "MetadataClient.publishPackageDrafts: packageId must be non-empty. The POST /packages/:id/publish-drafts route requires an id segment."
789
+ );
790
+ }
791
+ const url = `${packagesBaseOf(this.base)}/${encodeURIComponent(packageId)}/publish-drafts`;
792
+ const res = await this.sendWrite(url, {
793
+ method: "POST",
794
+ // Both call sites this replaces sent the session cookie, and the client's
795
+ // own fetch adds the console's Bearer token — so the routed call carries a
796
+ // superset of what the bare doors carried, never less.
797
+ credentials: "include",
798
+ headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
799
+ body: JSON.stringify(options.backfill !== void 0 ? { backfill: options.backfill } : {})
800
+ });
801
+ if (!res.ok) throw await parseError(res);
802
+ const body = unwrapDispatcherEnvelope(await res.json().catch(() => ({})));
803
+ for (const element of Array.isArray(body.published) ? body.published : []) {
804
+ if (!element || typeof element !== "object") continue;
805
+ const { type, name } = element;
806
+ if (typeof type !== "string" || typeof name !== "string") continue;
807
+ this.emitAdvisories(element, { type, name, door: "publish", mode: "publish" });
808
+ }
809
+ return body;
356
810
  }
357
811
  /**
358
812
  * Get the 3-state layered view of a metadata item: `code` (the packaged
@@ -394,7 +848,7 @@ var MetadataClient = class _MetadataClient {
394
848
  }
395
849
  if (!res.ok) throw await parseError(res);
396
850
  const body = await res.json();
397
- const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
851
+ const hasEnvelope = typeof body === "object" && body !== null && ("code" in body || "overlay" in body || "effective" in body);
398
852
  if (!hasEnvelope) {
399
853
  return {
400
854
  code: null,
@@ -403,14 +857,34 @@ var MetadataClient = class _MetadataClient {
403
857
  effective: body
404
858
  };
405
859
  }
860
+ const parsed = import_api.GetMetaItemLayeredResponseSchema.safeParse(body);
861
+ if (parsed.success) {
862
+ const envelope = parsed.data;
863
+ return {
864
+ code: envelope.code ?? null,
865
+ overlay: envelope.overlay ?? null,
866
+ overlayScope: envelope.overlayScope,
867
+ effective: envelope.effective ?? null,
868
+ ...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
869
+ lock: envelope.lock,
870
+ ...envelope.lockReason !== void 0 ? { lockReason: envelope.lockReason } : {},
871
+ ...envelope.lockSource !== void 0 ? { lockSource: envelope.lockSource } : {},
872
+ ...envelope.lockDocsUrl !== void 0 ? { lockDocsUrl: envelope.lockDocsUrl } : {},
873
+ ...envelope.provenance !== void 0 ? { provenance: envelope.provenance } : {},
874
+ ...envelope.packageId !== void 0 ? { packageId: envelope.packageId } : {},
875
+ ...envelope.packageVersion !== void 0 ? { packageVersion: envelope.packageVersion } : {},
876
+ editable: envelope.editable,
877
+ deletable: envelope.deletable,
878
+ resettable: envelope.resettable
879
+ };
880
+ }
881
+ const unrecognized = unrecognizedEnvelopeKeys(body);
406
882
  return {
407
883
  code: body.code ?? null,
408
884
  overlay: body.overlay ?? null,
409
885
  overlayScope: body.overlayScope ?? null,
410
886
  effective: body.effective ?? null,
411
887
  ...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
412
- // ADR-0010 Phase 4 — pass through the metadata-protection envelope so
413
- // the editor can render lock affordances without a second round trip.
414
888
  ...body.lock !== void 0 ? { lock: body.lock } : {},
415
889
  ...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
416
890
  ...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
@@ -420,7 +894,8 @@ var MetadataClient = class _MetadataClient {
420
894
  ...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
421
895
  ...body.editable !== void 0 ? { editable: body.editable } : {},
422
896
  ...body.deletable !== void 0 ? { deletable: body.deletable } : {},
423
- ...body.resettable !== void 0 ? { resettable: body.resettable } : {}
897
+ ...body.resettable !== void 0 ? { resettable: body.resettable } : {},
898
+ ...unrecognized.length > 0 ? { _unrecognized: unrecognized } : {}
424
899
  };
425
900
  }
426
901
  /**
@@ -471,13 +946,22 @@ var MetadataClient = class _MetadataClient {
471
946
  * Idempotent: returns the result even when no overlay row existed.
472
947
  * Pass `state: 'draft'` to discard the pending draft only (keeps the
473
948
  * published overlay intact) — useful for a Studio "Discard draft" button.
949
+ *
950
+ * Pass `packageId` to name the package whose row the delete removes, sent
951
+ * as `?package=`. A delete that names none addresses the one package that
952
+ * holds the item; when two or more packages hold it the door refuses it
953
+ * `409 RESOURCE_CONFLICT`, deletes nothing, and names `?package=` as the
954
+ * remedy. That refusal is thrown like any other {@link MetadataError}.
474
955
  */
475
956
  async reset(type, name, options = {}) {
476
- const qs = options.state === "draft" ? "?state=draft" : "";
957
+ const params = [];
958
+ if (options.state === "draft") params.push("state=draft");
959
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
960
+ const qs = params.length ? `?${params.join("&")}` : "";
477
961
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
478
962
  const headers = { ...this.headers };
479
963
  if (options.ifMatch) headers["If-Match"] = options.ifMatch;
480
- const res = await this.fetchImpl(url, { method: "DELETE", headers });
964
+ const res = await this.sendWrite(url, { method: "DELETE", headers });
481
965
  if (!res.ok) throw await parseError(res);
482
966
  return await res.json();
483
967
  }
@@ -487,20 +971,29 @@ var MetadataClient = class _MetadataClient {
487
971
  * `{ success, version, seq, message }`. Throws a `404 no_draft` if
488
972
  * nothing is pending and `409 metadata_conflict` if the published
489
973
  * overlay moved while the draft was sitting.
974
+ *
975
+ * Reports the runtime authoring gate's advisory findings through
976
+ * {@link MetadataClientConfig.onSaveAdvisory} when the response carries them
977
+ * (#5026 / objectstack#9176), with `door: 'publish'`. This is the door the
978
+ * Studio designer takes on every edit, so it is the one whose findings an
979
+ * author actually sees.
490
980
  */
491
981
  async publish(type, name, options = {}) {
492
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
982
+ const qs = options.packageId ? `?package=${encodeURIComponent(options.packageId)}` : "";
983
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish${qs}`;
493
984
  const headers = {
494
985
  ...this.headers,
495
986
  "Content-Type": "application/json"
496
987
  };
497
- const res = await this.fetchImpl(url, {
988
+ const res = await this.sendWrite(url, {
498
989
  method: "POST",
499
990
  headers,
500
991
  body: JSON.stringify(options.message ? { message: options.message } : {})
501
992
  });
502
993
  if (!res.ok) throw await parseError(res);
503
- return await res.json();
994
+ const body = await res.json();
995
+ this.emitAdvisories(body, { type, name, door: "publish", mode: "publish" });
996
+ return body;
504
997
  }
505
998
  /**
506
999
  * Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
@@ -516,7 +1009,7 @@ var MetadataClient = class _MetadataClient {
516
1009
  ...this.headers,
517
1010
  "Content-Type": "application/json"
518
1011
  };
519
- const res = await this.fetchImpl(url, {
1012
+ const res = await this.sendWrite(url, {
520
1013
  method: "POST",
521
1014
  headers,
522
1015
  body: JSON.stringify({
@@ -592,14 +1085,32 @@ function errorCodeIsAnyOf(err, codes) {
592
1085
 
593
1086
  // ../core/src/utils/filter-converter.ts
594
1087
  var import_ui = require("@objectstack/spec/ui");
1088
+ var import_data2 = require("@objectstack/spec/data");
1089
+
1090
+ // ../core/src/utils/text-comparand.ts
1091
+ var import_data = require("@objectstack/spec/data");
1092
+
1093
+ // ../core/src/utils/filter-converter.ts
595
1094
  var FilterOperatorError = class extends Error {
596
- constructor(message) {
1095
+ constructor(message, subject) {
597
1096
  super(message);
598
1097
  __publicField(this, "code", "INVALID_FILTER");
599
1098
  __publicField(this, "httpStatus", 400);
1099
+ /** @see FilterRefusalSubject */
1100
+ __publicField(this, "operator");
1101
+ /** @see FilterRefusalSubject */
1102
+ __publicField(this, "field");
600
1103
  this.name = "FilterOperatorError";
1104
+ this.operator = subject?.operator;
1105
+ this.field = subject?.field;
601
1106
  }
602
1107
  };
1108
+ var RETIRED_OPERATOR_ALIASES = {
1109
+ "$notin": "$nin",
1110
+ "$notcontains": "$notContains",
1111
+ "$startswith": "$startsWith",
1112
+ "$endswith": "$endsWith"
1113
+ };
603
1114
  function convertOperatorToAST(operator) {
604
1115
  const operatorMap = {
605
1116
  "$eq": "=",
@@ -610,27 +1121,148 @@ function convertOperatorToAST(operator) {
610
1121
  "$lte": "<=",
611
1122
  "$in": "in",
612
1123
  "$nin": "nin",
613
- "$notin": "nin",
614
1124
  "$between": "between",
615
1125
  "$contains": "contains",
616
1126
  "$notContains": "notcontains",
617
- "$notcontains": "notcontains",
618
1127
  "$startsWith": "startswith",
619
- "$startswith": "startswith",
620
1128
  "$endsWith": "endswith",
621
- "$endswith": "endswith"
1129
+ // Case-insensitive contains. A canonical `FILTER_OPERATORS` member that
1130
+ // `ValueDataSource` executes and `FilterConditionField` emits (for its
1131
+ // `icontains` builder row — spelled `containsCaseInsensitive` until
1132
+ // objectui#9306), while this map refused it with the
1133
+ // generic unknown-operator paragraph — so ONE authored filter selected rows
1134
+ // through the in-memory matcher and 400'd on the ObjectStack lowering path
1135
+ // (objectui#8976). The other direction of the same split objectui#8568 fixed:
1136
+ // there the map was MORE tolerant than the matcher, here it was LESS tolerant
1137
+ // than the contract. Restored by aligning the map with the spec, per AGENTS.md
1138
+ // #0 — not by removing the operator from the builder, which the spec declares.
1139
+ //
1140
+ // The VALUE is an IDENTITY, and that is not a typo: `icontains` is itself a
1141
+ // member of the spec's `VALID_AST_OPERATORS`, so unlike its camelCase siblings
1142
+ // above there is no case to squash. Same identity row, for the same stated
1143
+ // reason, that `FILTER_OPERATOR_ALIASES` carries in
1144
+ // `packages/data-objectstack/src/index.ts`.
1145
+ "$icontains": "icontains"
622
1146
  };
623
1147
  return operatorMap[operator] || null;
624
1148
  }
1149
+ var AST_LOGIC_KEYWORD = {
1150
+ $and: "and",
1151
+ $or: "or"
1152
+ };
1153
+ function lowerLogicalGroup(field, keyword, value) {
1154
+ if (!Array.isArray(value)) {
1155
+ throw new FilterOperatorError(
1156
+ `[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).`,
1157
+ { operator: field }
1158
+ );
1159
+ }
1160
+ if (value.length === 0) {
1161
+ return keyword === "and" ? void 0 : falseIdentityLeaf(field, value);
1162
+ }
1163
+ const children = [];
1164
+ let absorbed = false;
1165
+ for (const child of value) {
1166
+ if (child === null || typeof child !== "object" || Array.isArray(child)) {
1167
+ throw new FilterOperatorError(
1168
+ `[ObjectUI] Every member of '${field}' must be a filter condition OBJECT. Received ${JSON.stringify(child)}. Spec: FilterCondition declares '${field}?: FilterCondition[]' (data/filter.zod.ts).`,
1169
+ { operator: field }
1170
+ );
1171
+ }
1172
+ const lowered = convertFiltersToAST(child);
1173
+ if (!Array.isArray(lowered)) {
1174
+ if (keyword === "or") absorbed = true;
1175
+ continue;
1176
+ }
1177
+ children.push(lowered);
1178
+ }
1179
+ if (absorbed) return void 0;
1180
+ if (children.length === 0) return void 0;
1181
+ return [keyword, ...children];
1182
+ }
1183
+ function falseIdentityLeaf(field, value) {
1184
+ return [field, "=", value];
1185
+ }
1186
+ function isOperatorMap(value) {
1187
+ const proto = Object.getPrototypeOf(value);
1188
+ return proto === null || proto === Object.prototype || Object.getPrototypeOf(proto) === null;
1189
+ }
1190
+ function describeExoticComparand(value) {
1191
+ let name;
1192
+ let text2;
1193
+ try {
1194
+ const proto = Object.getPrototypeOf(value);
1195
+ name = proto?.constructor?.name;
1196
+ } catch {
1197
+ name = void 0;
1198
+ }
1199
+ if (typeof name !== "string" || name === "") return "an object of an unrecognised type";
1200
+ try {
1201
+ text2 = String(value);
1202
+ } catch {
1203
+ text2 = void 0;
1204
+ }
1205
+ return text2 && text2 !== `[object ${name}]` ? `a ${name} instance (${text2})` : `a ${name} instance`;
1206
+ }
1207
+ function refuseTextComparand(field, operator, target) {
1208
+ const ported = `It is refused here rather than lowered onto the wire (objectui#9001; ported from ValueDataSource's refuseTextComparand, objectui#8748).`;
1209
+ throw new FilterOperatorError(
1210
+ `[ObjectUI] The ${(0, import_data.textComparandRefusalReason)(field, operator, target)}. ${ported}`,
1211
+ { operator, field }
1212
+ );
1213
+ }
1214
+ function refuseEmptyOperatorMap(field) {
1215
+ throw new FilterOperatorError(
1216
+ `[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.`,
1217
+ { field }
1218
+ );
1219
+ }
625
1220
  function convertFiltersToAST(filter) {
626
1221
  const conditions = [];
1222
+ let trueIdentityGroups = 0;
1223
+ let skippedNullKeys = 0;
627
1224
  for (const [field, value] of Object.entries(filter)) {
628
- if (value === null || value === void 0) continue;
1225
+ if (value === null || value === void 0) {
1226
+ skippedNullKeys += 1;
1227
+ continue;
1228
+ }
1229
+ const logicKeyword = AST_LOGIC_KEYWORD[field];
1230
+ if (logicKeyword) {
1231
+ const group = lowerLogicalGroup(field, logicKeyword, value);
1232
+ if (group !== void 0) conditions.push(group);
1233
+ else trueIdentityGroups += 1;
1234
+ continue;
1235
+ }
1236
+ if (field === "$not") {
1237
+ throw new FilterOperatorError(
1238
+ `[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)}.`,
1239
+ { operator: "$not" }
1240
+ );
1241
+ }
1242
+ if (Array.isArray(value)) {
1243
+ throw new FilterOperatorError(
1244
+ `[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] } }.`,
1245
+ { field }
1246
+ );
1247
+ }
629
1248
  if (typeof value === "object" && !Array.isArray(value)) {
630
- for (const [operator, operatorValue] of Object.entries(value)) {
1249
+ if ((0, import_data2.isAcceptedFilterComparand)(value)) {
1250
+ conditions.push([field, "=", value]);
1251
+ continue;
1252
+ }
1253
+ if (!isOperatorMap(value)) {
1254
+ throw new FilterOperatorError(
1255
+ `[ObjectUI] The filter on field '${field}' carries ${describeExoticComparand(value)} in comparand position. '@objectstack/spec' does not accept it: a comparison value must be ${import_data2.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(...) } }.`,
1256
+ { field }
1257
+ );
1258
+ }
1259
+ const operatorEntries = Object.entries(value);
1260
+ if (operatorEntries.length === 0) refuseEmptyOperatorMap(field);
1261
+ for (const [operator, operatorValue] of operatorEntries) {
631
1262
  if (operator === "$regex") {
632
1263
  throw new FilterOperatorError(
633
- `[ObjectUI] The '$regex' filter operator is not supported. It used to be converted to 'contains', which matches a literal substring rather than a pattern \u2014 a different result, not a degraded one. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. Use $contains, $startsWith or $endsWith.`
1264
+ `[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.`,
1265
+ { operator: "$regex", field }
634
1266
  );
635
1267
  }
636
1268
  if (operator === "$null") {
@@ -641,12 +1273,33 @@ function convertFiltersToAST(filter) {
641
1273
  conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
642
1274
  continue;
643
1275
  }
1276
+ if (operator === "$empty") {
1277
+ if (operatorValue !== true && operatorValue !== false) {
1278
+ throw new FilterOperatorError(
1279
+ `[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.`,
1280
+ { operator: "$empty", field }
1281
+ );
1282
+ }
1283
+ conditions.push([field, operatorValue ? "is_empty" : "is_not_empty", true]);
1284
+ continue;
1285
+ }
644
1286
  const astOperator = convertOperatorToAST(operator);
645
1287
  if (astOperator) {
1288
+ if (astOperator === "icontains" && (0, import_data.isRefusedTextComparand)(operatorValue)) {
1289
+ refuseTextComparand(field, operator, operatorValue);
1290
+ }
646
1291
  conditions.push([field, astOperator, operatorValue]);
647
1292
  } else {
1293
+ const canonical = RETIRED_OPERATOR_ALIASES[operator];
1294
+ if (canonical) {
1295
+ throw new FilterOperatorError(
1296
+ `[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).`,
1297
+ { operator, field }
1298
+ );
1299
+ }
648
1300
  throw new FilterOperatorError(
649
- `[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, $notContains, $startsWith, $endsWith, $null, $exists. If you need exact object matching, use the value directly without an operator.`
1301
+ `[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.`,
1302
+ { operator, field }
650
1303
  );
651
1304
  }
652
1305
  }
@@ -655,6 +1308,13 @@ function convertFiltersToAST(filter) {
655
1308
  }
656
1309
  }
657
1310
  if (conditions.length === 0) {
1311
+ const keysTheLoopProcessed = Object.keys(filter).length - skippedNullKeys;
1312
+ if (trueIdentityGroups > 0 && trueIdentityGroups === keysTheLoopProcessed) {
1313
+ return void 0;
1314
+ }
1315
+ if (skippedNullKeys > 0 && skippedNullKeys === Object.keys(filter).length) {
1316
+ return void 0;
1317
+ }
658
1318
  return filter;
659
1319
  }
660
1320
  if (conditions.length === 1) {
@@ -662,6 +1322,49 @@ function convertFiltersToAST(filter) {
662
1322
  }
663
1323
  return ["and", ...conditions];
664
1324
  }
1325
+ var ARRAY_VALUED_VIEW_OPERATORS = /* @__PURE__ */ new Set([
1326
+ ...import_ui.VIEW_FILTER_LIST_VALUE_OPERATORS,
1327
+ ...import_ui.VIEW_FILTER_PAIR_VALUE_OPERATORS
1328
+ ]);
1329
+ var KNOWN_VIEW_OPERATORS = new Set(import_ui.VIEW_FILTER_OPERATORS);
1330
+
1331
+ // ../core/src/utils/column-sortability.ts
1332
+ var import_api2 = require("@objectstack/spec/api");
1333
+ var OBJECT_SORTABILITY_KEY = /* @__PURE__ */ Symbol.for("objectui.objectSortability");
1334
+ function asFieldSortability(value) {
1335
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
1336
+ const sortable = value.sortable;
1337
+ if (typeof sortable !== "boolean") return void 0;
1338
+ const reason = value.reason;
1339
+ const caveat = value.caveat;
1340
+ return {
1341
+ sortable,
1342
+ ...typeof reason === "string" ? { reason } : {},
1343
+ ...typeof caveat === "string" ? { caveat } : {}
1344
+ };
1345
+ }
1346
+ function normalizeObjectSortability(served) {
1347
+ if (!served || typeof served !== "object" || Array.isArray(served)) return void 0;
1348
+ const rawFields = served.fields;
1349
+ if (!rawFields || typeof rawFields !== "object" || Array.isArray(rawFields)) return void 0;
1350
+ const fields = {};
1351
+ for (const [name, entry] of Object.entries(rawFields)) {
1352
+ const verdict = asFieldSortability(entry);
1353
+ if (verdict) fields[name] = verdict;
1354
+ }
1355
+ return { fields };
1356
+ }
1357
+ function attachObjectSortability(schema, served) {
1358
+ const projection = normalizeObjectSortability(served);
1359
+ if (!projection || !schema || typeof schema !== "object") return schema;
1360
+ Object.defineProperty(schema, OBJECT_SORTABILITY_KEY, {
1361
+ value: projection,
1362
+ enumerable: false,
1363
+ configurable: true,
1364
+ writable: true
1365
+ });
1366
+ return schema;
1367
+ }
665
1368
 
666
1369
  // ../core/src/adapters/batchTransaction.ts
667
1370
  function idOf(rec) {
@@ -730,20 +1433,143 @@ async function emulateBatchTransaction(dataSource, operations) {
730
1433
  }
731
1434
 
732
1435
  // ../core/src/utils/reference-keys.ts
733
- function normalizeFieldReferenceKeys(fieldDef) {
1436
+ var import_data3 = require("@objectstack/spec/data");
1437
+ var aliasProbe = (key) => key.toLowerCase().replace(/[_\-\s]/g, "");
1438
+ var declaredFieldKeys = null;
1439
+ var probeFolds = null;
1440
+ var collidingProbes = null;
1441
+ function fieldKeyFolds() {
1442
+ if (probeFolds) return probeFolds;
1443
+ const declared = Object.keys(import_data3.FieldSchema.shape);
1444
+ const seen = /* @__PURE__ */ new Map();
1445
+ for (const key of declared) {
1446
+ const probe = aliasProbe(key);
1447
+ seen.set(probe, [...seen.get(probe) ?? [], key]);
1448
+ }
1449
+ const folds = /* @__PURE__ */ new Map();
1450
+ const collisions = /* @__PURE__ */ new Map();
1451
+ for (const [probe, keys] of seen) {
1452
+ if (keys.length === 1) folds.set(probe, keys[0]);
1453
+ else collisions.set(probe, keys);
1454
+ }
1455
+ declaredFieldKeys = new Set(declared);
1456
+ collidingProbes = collisions;
1457
+ probeFolds = folds;
1458
+ return folds;
1459
+ }
1460
+ var warnedRetiredSpelling = /* @__PURE__ */ new Set();
1461
+ var warnedUnfoldableSpelling = /* @__PURE__ */ new Set();
1462
+ function warnUnfoldableRetiredKey(reason, key, detail, fieldName, objectName, f) {
1463
+ if (!isDev()) return;
1464
+ const named = fieldName ?? (typeof f.name === "string" ? f.name : "(unnamed field)");
1465
+ const owner = objectName ?? "(unknown object)";
1466
+ const memo = `${owner}:${named}:${key}:${reason}`;
1467
+ if (warnedUnfoldableSpelling.has(memo)) return;
1468
+ const published = reason === "no-declared-twin" && key === "id_field" ? ` \`@objectstack/spec\`'s \`FIELD_KEY_GUIDANCE\` gives the reason: ${import_data3.FIELD_KEY_GUIDANCE.id_field.why}` : "";
1469
+ warnedUnfoldableSpelling.add(memo);
1470
+ console.warn(
1471
+ `[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)`
1472
+ );
1473
+ }
1474
+ function canonicalizeRetiredFieldKeys(f, fieldName, objectName) {
1475
+ const folds = fieldKeyFolds();
1476
+ for (const key of Object.keys(f)) {
1477
+ if (declaredFieldKeys.has(key)) continue;
1478
+ const value = f[key];
1479
+ if (value === void 0) continue;
1480
+ if (REFERENCE_ARM_KEYS.has(key)) continue;
1481
+ const probe = aliasProbe(key);
1482
+ const canonical = folds.get(probe);
1483
+ if (canonical === void 0) {
1484
+ const colliding = collidingProbes.get(probe);
1485
+ if (colliding) {
1486
+ warnUnfoldableRetiredKey(
1487
+ "ambiguous-probe",
1488
+ key,
1489
+ `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.`,
1490
+ fieldName,
1491
+ objectName,
1492
+ f
1493
+ );
1494
+ } else {
1495
+ warnUnfoldableRetiredKey(
1496
+ "no-declared-twin",
1497
+ key,
1498
+ `\`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.`,
1499
+ fieldName,
1500
+ objectName,
1501
+ f
1502
+ );
1503
+ }
1504
+ continue;
1505
+ }
1506
+ if (f[canonical] !== void 0) {
1507
+ if (f[canonical] !== value) {
1508
+ warnUnfoldableRetiredKey(
1509
+ "canonical-occupied",
1510
+ key,
1511
+ `\`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.`,
1512
+ fieldName,
1513
+ objectName,
1514
+ f
1515
+ );
1516
+ }
1517
+ continue;
1518
+ }
1519
+ f[canonical] = value;
1520
+ if (!isDev()) continue;
1521
+ const named = fieldName ?? (typeof f.name === "string" ? f.name : "(unnamed field)");
1522
+ const owner = objectName ?? "(unknown object)";
1523
+ const memo = `${owner}:${named}:${key}:${canonical}`;
1524
+ if (warnedRetiredSpelling.has(memo)) continue;
1525
+ warnedRetiredSpelling.add(memo);
1526
+ console.warn(
1527
+ `[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)`
1528
+ );
1529
+ }
1530
+ }
1531
+ var warnedLegacyOnly = /* @__PURE__ */ new Set();
1532
+ var isDev = () => globalThis.process?.env?.NODE_ENV !== "production";
1533
+ var LEGACY_REFERENCE_KEYS = ["reference_to", "referenceTo"];
1534
+ var REFERENCE_ARM_KEYS = new Set(LEGACY_REFERENCE_KEYS);
1535
+ function warnOnLegacyOnlyReference(f, fieldName, objectName) {
1536
+ if (!isDev()) return;
1537
+ if (f.reference !== void 0) return;
1538
+ const spelled = LEGACY_REFERENCE_KEYS.filter((k) => f[k] !== void 0 && f[k] !== "");
1539
+ if (spelled.length === 0) return;
1540
+ const named = fieldName ?? (typeof f.name === "string" ? f.name : "(unnamed field)");
1541
+ const owner = objectName ?? "(unknown object)";
1542
+ for (const key of spelled) {
1543
+ const memo = `${owner}:${named}:${key}:${String(f[key])}`;
1544
+ if (warnedLegacyOnly.has(memo)) continue;
1545
+ warnedLegacyOnly.add(memo);
1546
+ console.warn(
1547
+ `[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)`
1548
+ );
1549
+ }
1550
+ }
1551
+ function normalizeFieldReferenceKeys(fieldDef, fieldName, objectName) {
734
1552
  if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
735
1553
  const f = fieldDef;
736
- const target = f.reference_to ?? f.reference ?? f.referenceTo;
737
- if (target == null || target === "") return fieldDef;
738
- if (f.reference_to === void 0) f.reference_to = target;
739
- if (f.reference === void 0) f.reference = target;
1554
+ canonicalizeRetiredFieldKeys(f, fieldName, objectName);
1555
+ if (f.reference !== void 0) return fieldDef;
1556
+ const legacy = f.reference_to ?? f.referenceTo;
1557
+ if (legacy == null || legacy === "") return fieldDef;
1558
+ warnOnLegacyOnlyReference(f, fieldName, objectName);
1559
+ f.reference = legacy;
740
1560
  return fieldDef;
741
1561
  }
742
1562
  function normalizeSchemaReferenceKeys(schema) {
743
1563
  const fields = schema && typeof schema === "object" ? schema.fields : null;
744
1564
  if (!fields || typeof fields !== "object") return schema;
745
- const defs = Array.isArray(fields) ? fields : Object.values(fields);
746
- for (const def of defs) normalizeFieldReferenceKeys(def);
1565
+ const objectName = typeof schema.name === "string" ? schema.name : void 0;
1566
+ if (Array.isArray(fields)) {
1567
+ for (const def of fields) normalizeFieldReferenceKeys(def, void 0, objectName);
1568
+ } else {
1569
+ for (const [name, def] of Object.entries(fields)) {
1570
+ normalizeFieldReferenceKeys(def, name, objectName);
1571
+ }
1572
+ }
747
1573
  return schema;
748
1574
  }
749
1575
 
@@ -796,21 +1622,54 @@ var MetadataCache = class {
796
1622
  this.cache.delete(key);
797
1623
  }
798
1624
  }
1625
+ return this.load(key, fetcher);
1626
+ }
1627
+ /**
1628
+ * Join the read in flight for `key`, or start one, and never answer from the
1629
+ * settled entry: a caller of this always gets an answer from a request that
1630
+ * was on the wire when it asked.
1631
+ *
1632
+ * objectui#13044. This is the door for a caller whose own contract is "ask
1633
+ * the server", which should still not put a second identical request on the
1634
+ * wire while one is already there.
1635
+ *
1636
+ * objectui#13057. The landing is stored, exactly as one {@link get} started
1637
+ * would store it, so a later {@link get} for `key` may be answered from it.
1638
+ * The caller asked the server; a reader that may be served what the cache
1639
+ * already holds is served the answer that caller just received.
1640
+ */
1641
+ share(key, fetcher) {
1642
+ return this.load(key, fetcher);
1643
+ }
1644
+ /**
1645
+ * The in-flight half of {@link get} and {@link share}: join the request in
1646
+ * flight for `key`, or start one.
1647
+ *
1648
+ * A landing is stored, and its in-flight entry cleared, only
1649
+ * while it is still the request registered for `key` (objectui#13044). {@link invalidate}
1650
+ * and {@link clear} detach the request in flight, so a request sent before an
1651
+ * invalidation can neither answer a caller who asks after it nor write its
1652
+ * answer over a newer one.
1653
+ */
1654
+ load(key, fetcher) {
799
1655
  const existing = this.inflight.get(key);
800
1656
  if (existing) {
801
1657
  this.stats.coalesced++;
802
1658
  return existing;
803
1659
  }
804
1660
  this.stats.misses++;
805
- const promise = (async () => {
1661
+ const self = {};
1662
+ const run = async () => {
806
1663
  try {
807
1664
  const data = await fetcher();
808
- this.set(key, data);
1665
+ if (this.inflight.get(key) === self.promise) this.set(key, data);
809
1666
  return data;
810
1667
  } finally {
811
- this.inflight.delete(key);
1668
+ if (this.inflight.get(key) === self.promise) this.inflight.delete(key);
812
1669
  }
813
- })();
1670
+ };
1671
+ const promise = run();
1672
+ self.promise = promise;
814
1673
  this.inflight.set(key, promise);
815
1674
  return promise;
816
1675
  }
@@ -858,8 +1717,10 @@ var MetadataCache = class {
858
1717
  invalidate(key) {
859
1718
  if (key) {
860
1719
  this.cache.delete(key);
1720
+ this.inflight.delete(key);
861
1721
  } else {
862
1722
  this.cache.clear();
1723
+ this.inflight.clear();
863
1724
  }
864
1725
  }
865
1726
  /**
@@ -929,6 +1790,21 @@ var MetadataCache = class {
929
1790
  }
930
1791
  };
931
1792
 
1793
+ // src/metadata-error.ts
1794
+ function extractIssues(e) {
1795
+ const issues = e?.issues;
1796
+ return Array.isArray(issues) ? issues : [];
1797
+ }
1798
+ function formatMetadataIssue(i) {
1799
+ return `\u2022 ${i.path && i.path.length > 0 ? i.path : "(root)"} \u2014 ${i.message}`;
1800
+ }
1801
+ function formatMetadataError(e) {
1802
+ const issues = extractIssues(e);
1803
+ if (issues.length > 0) return issues.map(formatMetadataIssue).join("\n");
1804
+ const err = e;
1805
+ return err?.message ?? String(e);
1806
+ }
1807
+
932
1808
  // src/errors.ts
933
1809
  var ObjectStackError = class extends Error {
934
1810
  /**
@@ -1116,53 +1992,88 @@ function isErrorType(error, errorClass) {
1116
1992
  return error instanceof errorClass;
1117
1993
  }
1118
1994
 
1995
+ // src/error-envelope.ts
1996
+ function readErrorEnvelope(body) {
1997
+ if (!body || typeof body !== "object") return {};
1998
+ const env = body;
1999
+ const nested = env.error && typeof env.error === "object" ? env.error : void 0;
2000
+ const reading = {};
2001
+ const message = text(nested?.message) ?? text(env.message) ?? text(env.error);
2002
+ if (message !== void 0) reading.message = message;
2003
+ const code = present(env.code) ?? present(nested?.code);
2004
+ if (code !== void 0) reading.code = code;
2005
+ return reading;
2006
+ }
2007
+ function text(value) {
2008
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
2009
+ }
2010
+ function present(value) {
2011
+ return typeof value === "string" ? value : void 0;
2012
+ }
2013
+
1119
2014
  // src/userState.ts
1120
2015
  var DEFAULT_RESOURCE = "sys_user_preference";
1121
- function createObjectStackUserStateAdapter(options) {
1122
- const {
1123
- dataSource,
1124
- userId,
1125
- key,
1126
- resource = DEFAULT_RESOURCE,
1127
- onError = () => {
2016
+ var PREFERENCE_FAMILY_ROW_LIMIT = 200;
2017
+ function preferenceQuery(userId, keys, prefixes = []) {
2018
+ if (prefixes.length === 0) {
2019
+ if (keys.length === 1) {
2020
+ return { $filter: { user_id: userId, key: keys[0] }, $top: 1 };
1128
2021
  }
1129
- } = options;
2022
+ return { $filter: { user_id: userId, key: { $in: [...keys] } }, $top: keys.length };
2023
+ }
2024
+ const arms = [];
2025
+ if (keys.length === 1) arms.push({ key: keys[0] });
2026
+ else if (keys.length > 1) arms.push({ key: { $in: [...keys] } });
2027
+ for (const prefix of prefixes) arms.push({ key: { $startsWith: prefix } });
2028
+ return {
2029
+ $filter: { user_id: userId, ...arms.length === 1 ? arms[0] : { $or: arms } },
2030
+ $top: keys.length + prefixes.length * PREFERENCE_FAMILY_ROW_LIMIT
2031
+ };
2032
+ }
2033
+ var openReads = /* @__PURE__ */ new WeakMap();
2034
+ function joinSharedRead(dataSource, resource, userId, want) {
2035
+ let scopes = openReads.get(dataSource);
2036
+ if (!scopes) {
2037
+ scopes = /* @__PURE__ */ new Map();
2038
+ openReads.set(dataSource, scopes);
2039
+ }
2040
+ const scope = JSON.stringify([resource, userId]);
2041
+ const open = scopes.get(scope);
2042
+ if (open) {
2043
+ if ("key" in want) open.keys.add(want.key);
2044
+ else open.prefixes.add(want.prefix);
2045
+ return open;
2046
+ }
2047
+ const keys = new Set("key" in want ? [want.key] : []);
2048
+ const prefixes = new Set("prefix" in want ? [want.prefix] : []);
2049
+ const opened = scopes;
2050
+ const read = {
2051
+ keys,
2052
+ prefixes,
2053
+ answer: Promise.resolve().then(async () => {
2054
+ if (opened.get(scope) === read) opened.delete(scope);
2055
+ const query = preferenceQuery(userId, [...keys], [...prefixes]);
2056
+ const result = await dataSource.find(resource, query);
2057
+ const rows = result?.data ?? [];
2058
+ return {
2059
+ rows,
2060
+ saturated: prefixes.size > 0 && rows.length >= (query.$top ?? Infinity)
2061
+ };
2062
+ })
2063
+ };
2064
+ scopes.set(scope, read);
2065
+ return read;
2066
+ }
2067
+ function createPreferenceRowWriter(dataSource, resource, userId, key, onError) {
1130
2068
  let cachedRowId = null;
1131
- let saveChain = Promise.resolve();
1132
2069
  const findExisting = async () => {
1133
- const params = {
1134
- $filter: {
1135
- user_id: userId,
1136
- key
1137
- },
1138
- $top: 1
1139
- };
1140
- const result = await dataSource.find(resource, params);
1141
- const rows = result?.data ?? [];
1142
- const match = rows.find((r) => {
1143
- if (!r) return false;
1144
- const userMismatch = r.user_id !== void 0 && r.user_id !== userId;
1145
- const keyMismatch = r.key !== void 0 && r.key !== key;
1146
- return !userMismatch && !keyMismatch;
1147
- });
1148
- return match ?? null;
1149
- };
1150
- const decodeValue = (value) => {
1151
- if (Array.isArray(value)) return value;
1152
- if (typeof value === "string") {
1153
- try {
1154
- const parsed = JSON.parse(value);
1155
- return Array.isArray(parsed) ? parsed : [];
1156
- } catch {
1157
- return [];
1158
- }
1159
- }
1160
- return [];
2070
+ const result = await dataSource.find(resource, preferenceQuery(userId, [key]));
2071
+ return matchPreferenceRow(result?.data ?? [], userId, key);
1161
2072
  };
1162
- const upsert = async (items) => {
2073
+ const upsert = async (value) => {
1163
2074
  if (cachedRowId !== null) {
1164
2075
  try {
1165
- await dataSource.update(resource, cachedRowId, { value: items });
2076
+ await dataSource.update(resource, cachedRowId, { value });
1166
2077
  return;
1167
2078
  } catch (updateError) {
1168
2079
  cachedRowId = null;
@@ -1172,14 +2083,14 @@ function createObjectStackUserStateAdapter(options) {
1172
2083
  const existing = await findExisting();
1173
2084
  if (existing && existing.id !== void 0 && existing.id !== null) {
1174
2085
  cachedRowId = existing.id;
1175
- await dataSource.update(resource, existing.id, { value: items });
2086
+ await dataSource.update(resource, existing.id, { value });
1176
2087
  return;
1177
2088
  }
1178
2089
  try {
1179
2090
  const created = await dataSource.create(resource, {
1180
2091
  user_id: userId,
1181
2092
  key,
1182
- value: items
2093
+ value
1183
2094
  });
1184
2095
  const newId = created?.id;
1185
2096
  if (newId !== void 0 && newId !== null) cachedRowId = newId;
@@ -1187,21 +2098,74 @@ function createObjectStackUserStateAdapter(options) {
1187
2098
  const recovered = await findExisting();
1188
2099
  if (recovered && recovered.id !== void 0 && recovered.id !== null) {
1189
2100
  cachedRowId = recovered.id;
1190
- await dataSource.update(resource, recovered.id, { value: items });
2101
+ await dataSource.update(resource, recovered.id, { value });
1191
2102
  return;
1192
2103
  }
1193
2104
  throw createError;
1194
2105
  }
1195
2106
  };
2107
+ return {
2108
+ remember(id) {
2109
+ cachedRowId = id;
2110
+ },
2111
+ upsert,
2112
+ find: findExisting
2113
+ };
2114
+ }
2115
+ function matchPreferenceRow(rows, userId, key) {
2116
+ const match = rows.find((r) => {
2117
+ if (!r) return false;
2118
+ const userMismatch = r.user_id !== void 0 && r.user_id !== userId;
2119
+ const keyMismatch = r.key !== void 0 && r.key !== key;
2120
+ return !userMismatch && !keyMismatch;
2121
+ });
2122
+ return match ?? null;
2123
+ }
2124
+ function createObjectStackUserStateAdapter(options) {
2125
+ const {
2126
+ dataSource,
2127
+ userId,
2128
+ key,
2129
+ resource = DEFAULT_RESOURCE,
2130
+ onError = () => {
2131
+ }
2132
+ } = options;
2133
+ const writer = createPreferenceRowWriter(dataSource, resource, userId, key, onError);
2134
+ let saveChain = Promise.resolve();
2135
+ const loadExisting = async () => {
2136
+ const read = joinSharedRead(dataSource, resource, userId, { key });
2137
+ const { rows, saturated } = await read.answer;
2138
+ if (read.keys.size === 1 && read.prefixes.size === 0) {
2139
+ return matchPreferenceRow(rows, userId, key);
2140
+ }
2141
+ const own = rows.find(
2142
+ (r) => r && r.key === key && (r.user_id === void 0 || r.user_id === userId)
2143
+ );
2144
+ if (own) return own;
2145
+ if (saturated || rows.some((r) => r && r.key === void 0)) return writer.find();
2146
+ return null;
2147
+ };
2148
+ const decodeValue = (value) => {
2149
+ if (Array.isArray(value)) return value;
2150
+ if (typeof value === "string") {
2151
+ try {
2152
+ const parsed = JSON.parse(value);
2153
+ return Array.isArray(parsed) ? parsed : [];
2154
+ } catch {
2155
+ return [];
2156
+ }
2157
+ }
2158
+ return [];
2159
+ };
1196
2160
  return {
1197
2161
  async load() {
1198
2162
  try {
1199
- const row = await findExisting();
2163
+ const row = await loadExisting();
1200
2164
  if (!row) {
1201
- cachedRowId = null;
2165
+ writer.remember(null);
1202
2166
  return [];
1203
2167
  }
1204
- if (row.id !== void 0 && row.id !== null) cachedRowId = row.id;
2168
+ if (row.id !== void 0 && row.id !== null) writer.remember(row.id);
1205
2169
  return decodeValue(row.value);
1206
2170
  } catch (error) {
1207
2171
  onError("load", error);
@@ -1209,7 +2173,7 @@ function createObjectStackUserStateAdapter(options) {
1209
2173
  }
1210
2174
  },
1211
2175
  async save(items) {
1212
- const run = saveChain.then(() => upsert(items)).catch((error) => {
2176
+ const run = saveChain.then(() => writer.upsert(items)).catch((error) => {
1213
2177
  onError("save", error);
1214
2178
  });
1215
2179
  saveChain = run;
@@ -1217,6 +2181,137 @@ function createObjectStackUserStateAdapter(options) {
1217
2181
  }
1218
2182
  };
1219
2183
  }
2184
+ function createObjectStackUserPreferenceFamily(options) {
2185
+ const {
2186
+ dataSource,
2187
+ userId,
2188
+ prefix,
2189
+ resource = DEFAULT_RESOURCE,
2190
+ onError = () => {
2191
+ }
2192
+ } = options;
2193
+ const writers = /* @__PURE__ */ new Map();
2194
+ const chains = /* @__PURE__ */ new Map();
2195
+ const writerFor = (key) => {
2196
+ let writer = writers.get(key);
2197
+ if (!writer) {
2198
+ writer = createPreferenceRowWriter(dataSource, resource, userId, key, onError);
2199
+ writers.set(key, writer);
2200
+ }
2201
+ return writer;
2202
+ };
2203
+ const decodeValue = (value) => {
2204
+ let parsed = value;
2205
+ if (typeof value === "string") {
2206
+ try {
2207
+ parsed = JSON.parse(value);
2208
+ } catch {
2209
+ return void 0;
2210
+ }
2211
+ }
2212
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
2213
+ };
2214
+ return {
2215
+ prefix,
2216
+ async load() {
2217
+ try {
2218
+ const read = joinSharedRead(dataSource, resource, userId, { prefix });
2219
+ const { rows } = await read.answer;
2220
+ const out = {};
2221
+ for (const row of rows) {
2222
+ if (!row || typeof row.key !== "string" || !row.key.startsWith(prefix)) continue;
2223
+ if (row.user_id !== void 0 && row.user_id !== userId) continue;
2224
+ if (row.id !== void 0 && row.id !== null) writerFor(row.key).remember(row.id);
2225
+ const value = decodeValue(row.value);
2226
+ if (value !== void 0) out[row.key] = value;
2227
+ }
2228
+ return out;
2229
+ } catch (error) {
2230
+ onError("load", error);
2231
+ return {};
2232
+ }
2233
+ },
2234
+ async save(key, value) {
2235
+ if (!key.startsWith(prefix) || key.length === prefix.length) {
2236
+ onError("save", new Error(`key "${key}" is not in the family "${prefix}"`));
2237
+ return;
2238
+ }
2239
+ const run = (chains.get(key) ?? Promise.resolve()).then(() => writerFor(key).upsert(value)).catch((error) => {
2240
+ onError("save", error);
2241
+ });
2242
+ chains.set(key, run);
2243
+ return run;
2244
+ }
2245
+ };
2246
+ }
2247
+
2248
+ // src/draft-envelope.ts
2249
+ var import_kernel = require("@objectstack/spec/kernel");
2250
+ function extractDraftBody(resp) {
2251
+ if (!resp || typeof resp !== "object") return null;
2252
+ const env = resp;
2253
+ if (!("item" in env)) return null;
2254
+ const body = env.item;
2255
+ if (!body || typeof body !== "object") return null;
2256
+ if (Object.keys(body).length === 0) return null;
2257
+ return (0, import_kernel.stripReadDecorations)(body);
2258
+ }
2259
+ function readDraftChanges(resp) {
2260
+ if (!resp || typeof resp !== "object") return null;
2261
+ const changes = resp.draftChanges;
2262
+ return Array.isArray(changes) ? changes : null;
2263
+ }
2264
+ function isPathPrefix(prefix, path) {
2265
+ if (prefix.length > path.length) return false;
2266
+ for (let i = 0; i < prefix.length; i++) if (prefix[i] !== path[i]) return false;
2267
+ return true;
2268
+ }
2269
+ function governingDraftChange(changes, path) {
2270
+ let best = null;
2271
+ for (const change of changes ?? []) {
2272
+ if (!isPathPrefix(change.path, path)) continue;
2273
+ if (!best || change.path.length > best.path.length) best = change;
2274
+ }
2275
+ return best;
2276
+ }
2277
+ function draftChangesUnder(changes, path) {
2278
+ return (changes ?? []).filter((change) => change.path.length > path.length && isPathPrefix(path, change.path));
2279
+ }
2280
+
2281
+ // src/picklist-binding.ts
2282
+ function dropServedPicklistOptions(body) {
2283
+ if (!body || typeof body !== "object") return body;
2284
+ const fields = body.fields;
2285
+ if (Array.isArray(fields)) {
2286
+ let changed = false;
2287
+ const next = fields.map((def) => {
2288
+ const stripped = withoutServedOptions(def);
2289
+ if (stripped !== def) changed = true;
2290
+ return stripped;
2291
+ });
2292
+ return changed ? { ...body, fields: next } : body;
2293
+ }
2294
+ if (fields && typeof fields === "object") {
2295
+ let changed = false;
2296
+ const next = Object.fromEntries(
2297
+ Object.entries(fields).map(([name, def]) => {
2298
+ const stripped = withoutServedOptions(def);
2299
+ if (stripped !== def) changed = true;
2300
+ return [name, stripped];
2301
+ })
2302
+ );
2303
+ return changed ? { ...body, fields: next } : body;
2304
+ }
2305
+ return body;
2306
+ }
2307
+ function withoutServedOptions(def) {
2308
+ if (!def || typeof def !== "object" || Array.isArray(def)) return def;
2309
+ const record = def;
2310
+ if (record.picklist === void 0) return def;
2311
+ if (!Object.prototype.hasOwnProperty.call(record, "options")) return def;
2312
+ const { options: _served, ...authored } = record;
2313
+ return authored;
2314
+ }
1220
2315
 
1221
2316
  // src/index.ts
1222
2317
  var FILTER_OPERATOR_ALIASES = {
@@ -1275,37 +2370,136 @@ var FILTER_OPERATOR_ALIASES = {
1275
2370
  before: "<",
1276
2371
  after: ">"
1277
2372
  };
1278
- function normalizeFilterOperator2(op) {
1279
- if (typeof op !== "string") return null;
1280
- const lower = op.toLowerCase();
1281
- return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
2373
+ function toAstFilterOperator(op) {
2374
+ if (typeof op !== "string") return null;
2375
+ const lower = op.toLowerCase();
2376
+ return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
2377
+ }
2378
+ function describeFilterEntry(entry) {
2379
+ try {
2380
+ return JSON.stringify(entry, (_key, value) => typeof value === "bigint" ? `${value}n` : value) ?? String(entry);
2381
+ } catch {
2382
+ return String(entry);
2383
+ }
2384
+ }
2385
+ var MalformedFilterError = class extends Error {
2386
+ constructor(entry, index) {
2387
+ const shown = describeFilterEntry(entry);
2388
+ super(
2389
+ `Filter entry ${index} is not a usable filter rule (${shown}). Expected { field, operator, value } with a non-empty field.`
2390
+ );
2391
+ __publicField(this, "code", "INVALID_FILTER");
2392
+ __publicField(this, "httpStatus", 400);
2393
+ __publicField(this, "entry");
2394
+ __publicField(this, "index");
2395
+ this.name = "MalformedFilterError";
2396
+ this.entry = entry;
2397
+ this.index = index;
2398
+ }
2399
+ };
2400
+ function isMalformedFilterError(error) {
2401
+ if (!error || typeof error !== "object") return false;
2402
+ const e = error;
2403
+ return e.code === "INVALID_FILTER" || e.name === "MalformedFilterError";
2404
+ }
2405
+ var UnloweredAggregateWhereError = class extends Error {
2406
+ constructor(where, resource) {
2407
+ const shown = JSON.stringify(where) ?? String(where);
2408
+ super(
2409
+ `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.`
2410
+ );
2411
+ __publicField(this, "code", "INVALID_FILTER");
2412
+ __publicField(this, "httpStatus", 400);
2413
+ /** The value as received, so a caller can log what its producer actually built. */
2414
+ __publicField(this, "where");
2415
+ /** The object `aggregate()` was called for. */
2416
+ __publicField(this, "resource");
2417
+ this.name = "UnloweredAggregateWhereError";
2418
+ this.where = where;
2419
+ this.resource = resource;
2420
+ }
2421
+ };
2422
+ function assertSpecShapeWhereIsFilterAst(where, resource) {
2423
+ if (!Array.isArray(where)) return;
2424
+ if (where.length === 0) return;
2425
+ if ((0, import_data5.isFilterAST)(where)) return;
2426
+ throw new UnloweredAggregateWhereError(where, resource);
2427
+ }
2428
+ var ANALYTICS_ONLY_AGGREGATE_KEYS = ["filter", "field", "function"];
2429
+ var ANALYTICS_ONLY_KEY_ADVICE = {
2430
+ 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.",
2431
+ 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' }].",
2432
+ function: "`function` is the other half of that measure pair and belongs inside an `aggregations` node, not beside `groupBy`."
2433
+ };
2434
+ function specShapeSelectorReasons(params) {
2435
+ const reasons = [];
2436
+ if (Array.isArray(params?.groupBy)) reasons.push("`groupBy` is an array");
2437
+ if (Array.isArray(params?.aggregations)) reasons.push("`aggregations` is an array");
2438
+ if (params?.where !== void 0) reasons.push("a `where` key is present");
2439
+ return reasons;
2440
+ }
2441
+ var AnalyticsKeysOnSpecShapeError = class extends Error {
2442
+ constructor(keys, params, resource) {
2443
+ const received = {};
2444
+ for (const key of keys) received[key] = params?.[key];
2445
+ const named = keys.map((k) => `\`${k}\``).join(", ");
2446
+ const shown = keys.map((k) => `${k}=${JSON.stringify(params?.[k]) ?? String(params?.[k])}`).join(", ");
2447
+ const why = specShapeSelectorReasons(params).join(" and ");
2448
+ 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." : "";
2449
+ super(
2450
+ `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."
2451
+ );
2452
+ __publicField(this, "code", "INVALID_FILTER");
2453
+ __publicField(this, "httpStatus", 400);
2454
+ /** The offending keys, in the order {@link ANALYTICS_ONLY_AGGREGATE_KEYS} lists them. */
2455
+ __publicField(this, "keys");
2456
+ /** What each offending key carried, so a producer is identifiable from a log. */
2457
+ __publicField(this, "received");
2458
+ /** The object `aggregate()` was called for. */
2459
+ __publicField(this, "resource");
2460
+ this.name = "AnalyticsKeysOnSpecShapeError";
2461
+ this.keys = keys;
2462
+ this.received = received;
2463
+ this.resource = resource;
2464
+ }
2465
+ };
2466
+ function assertNoAnalyticsKeysOnSpecShape(params, resource) {
2467
+ const present2 = ANALYTICS_ONLY_AGGREGATE_KEYS.filter((key) => params?.[key] != null);
2468
+ if (present2.length === 0) return;
2469
+ throw new AnalyticsKeysOnSpecShapeError(present2, params, resource);
1282
2470
  }
1283
- var MalformedFilterError = class extends Error {
1284
- constructor(entry, index) {
1285
- const shown = JSON.stringify(entry) ?? String(entry);
2471
+ var UnlowerableAnalyticsFilterError = class extends Error {
2472
+ constructor(filter, resource, detail) {
2473
+ const shown = JSON.stringify(filter) ?? String(filter);
1286
2474
  super(
1287
- `Filter entry ${index} is not a usable filter rule (${shown}). Expected { field, operator, value } with a non-empty field.`
2475
+ `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."
1288
2476
  );
1289
2477
  __publicField(this, "code", "INVALID_FILTER");
1290
2478
  __publicField(this, "httpStatus", 400);
1291
- __publicField(this, "entry");
1292
- __publicField(this, "index");
1293
- this.name = "MalformedFilterError";
1294
- this.entry = entry;
1295
- this.index = index;
2479
+ /** The filter as received, so a caller can log what its producer actually built. */
2480
+ __publicField(this, "filter");
2481
+ /** The object `aggregate()` was called for. */
2482
+ __publicField(this, "resource");
2483
+ this.name = "UnlowerableAnalyticsFilterError";
2484
+ this.filter = filter;
2485
+ this.resource = resource;
1296
2486
  }
1297
2487
  };
1298
- function isMalformedFilterError(error) {
1299
- if (!error || typeof error !== "object") return false;
1300
- const e = error;
1301
- return e.code === "INVALID_FILTER" || e.name === "MalformedFilterError";
2488
+ function refuseTextComparandEntry(entry, index, field, arrived) {
2489
+ 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).`;
2490
+ const error = new MalformedFilterError(entry, index);
2491
+ error.message = `Filter entry ${index} is refused. The ${(0, import_data6.textComparandRefusalReason)(field, arrived, entry.value)}. ${tail}`;
2492
+ throw error;
1302
2493
  }
1303
- function objectFilterEntryToAST(entry) {
2494
+ function objectFilterEntryToAST(entry, index) {
1304
2495
  if (!entry || typeof entry !== "object") return null;
1305
2496
  const field = entry.field;
1306
2497
  const rawOp = entry.operator ?? entry.op ?? "=";
1307
- const op = normalizeFilterOperator2(rawOp);
2498
+ const op = toAstFilterOperator(rawOp);
1308
2499
  if (!field || !op) return null;
2500
+ if (op === "icontains" && (0, import_data6.isRefusedTextComparand)(entry.value)) {
2501
+ refuseTextComparandEntry(entry, index, String(field), rawOp);
2502
+ }
1309
2503
  return [String(field), op, entry.value];
1310
2504
  }
1311
2505
  function isObjectFilterEntryForm(filter) {
@@ -1314,8 +2508,8 @@ function isObjectFilterEntryForm(filter) {
1314
2508
  }
1315
2509
  function objectFilterEntriesToAST(entries) {
1316
2510
  const nodes = entries.map((entry, i) => {
1317
- if (Array.isArray(entry)) return translateFilterChild(entry);
1318
- const tuple = objectFilterEntryToAST(entry);
2511
+ if (Array.isArray(entry)) return translateFilterChild(entry, i);
2512
+ const tuple = objectFilterEntryToAST(entry, i);
1319
2513
  if (!tuple) throw new MalformedFilterError(entry, i);
1320
2514
  return tuple;
1321
2515
  });
@@ -1326,15 +2520,17 @@ function translateFilterArray(filter) {
1326
2520
  if (isObjectFilterEntryForm(filter)) return objectFilterEntriesToAST(filter);
1327
2521
  const head = filter[0];
1328
2522
  if (typeof head === "string" && LOGICAL_AST_HEADS.has(head.toLowerCase())) {
1329
- return [head, ...filter.slice(1).map(translateFilterChild)];
2523
+ return [head, ...filter.slice(1).map((child, i) => translateFilterChild(child, i + 1))];
2524
+ }
2525
+ if (filter.every((child) => Array.isArray(child))) {
2526
+ return filter.map((child, i) => translateFilterChild(child, i));
1330
2527
  }
1331
- if (filter.every((child) => Array.isArray(child))) return filter.map(translateFilterChild);
1332
2528
  return filter;
1333
2529
  }
1334
- function translateFilterChild(child) {
2530
+ function translateFilterChild(child, index) {
1335
2531
  if (Array.isArray(child)) return child.length > 0 ? translateFilterArray(child) : child;
1336
2532
  if (child && typeof child === "object" && child.field !== void 0) {
1337
- const tuple = objectFilterEntryToAST(child);
2533
+ const tuple = objectFilterEntryToAST(child, index);
1338
2534
  if (tuple) return tuple;
1339
2535
  }
1340
2536
  return child;
@@ -1351,6 +2547,30 @@ function translateFilterToAST(filter) {
1351
2547
  }
1352
2548
  return void 0;
1353
2549
  }
2550
+ function lowerAnalyticsFilterForWire(filter, resource) {
2551
+ if (!Array.isArray(filter)) return filter;
2552
+ if (filter.length === 0) return void 0;
2553
+ const ast = translateFilterArray(filter);
2554
+ if (!(0, import_data5.isFilterAST)(ast)) throw new UnlowerableAnalyticsFilterError(filter, resource);
2555
+ let lowered;
2556
+ try {
2557
+ lowered = (0, import_data5.parseFilterAST)(ast);
2558
+ } catch (e) {
2559
+ throw new UnlowerableAnalyticsFilterError(
2560
+ filter,
2561
+ resource,
2562
+ e instanceof Error ? e.message : String(e)
2563
+ );
2564
+ }
2565
+ if (lowered === void 0) {
2566
+ throw new UnlowerableAnalyticsFilterError(
2567
+ filter,
2568
+ resource,
2569
+ "`parseFilterAST` answered `undefined` for a non-empty filter"
2570
+ );
2571
+ }
2572
+ return lowered;
2573
+ }
1354
2574
  function serializeOrderBy(orderby) {
1355
2575
  if (orderby === void 0 || orderby === null) return void 0;
1356
2576
  if (typeof orderby === "string") {
@@ -1368,6 +2588,7 @@ function serializeOrderBy(orderby) {
1368
2588
  }
1369
2589
  return void 0;
1370
2590
  }
2591
+ var VIEW_LIST_KEY = "list:view";
1371
2592
  var discoveryCache = /* @__PURE__ */ new Map();
1372
2593
  async function getSharedDiscovery(baseUrl, fetcher) {
1373
2594
  const key = baseUrl || "<default>";
@@ -1413,6 +2634,14 @@ var APP_PERMISSION_DENIED_CODE = "PERMISSION_DENIED";
1413
2634
  function isAppPermissionDeniedError(error) {
1414
2635
  return errorCodeIs(error, APP_PERMISSION_DENIED_CODE);
1415
2636
  }
2637
+ var META_ITEM_ABSENT_CODE = "RESOURCE_NOT_FOUND";
2638
+ function isMetaItemAbsentError(error) {
2639
+ return errorCodeIs(error, META_ITEM_ABSENT_CODE);
2640
+ }
2641
+ function metaEnvelopeCarriesItem(envelope) {
2642
+ if (envelope == null || typeof envelope !== "object") return false;
2643
+ return envelope.item != null;
2644
+ }
1416
2645
  var AnalyticsNotInstalledError = class extends Error {
1417
2646
  constructor(surface, detail, serverCode) {
1418
2647
  super(
@@ -1481,6 +2710,25 @@ var AnalyticsUnauthenticatedError = class extends Error {
1481
2710
  this.serverMessage = opts.serverMessage;
1482
2711
  }
1483
2712
  };
2713
+ var AnalyticsForbiddenError = class extends Error {
2714
+ constructor(opts = {}) {
2715
+ super(
2716
+ `Analytics query refused: this user may not read the data behind ${opts.datasetName ? `dataset "${opts.datasetName}"` : "this dataset"}.` + (opts.serverMessage ? ` (server said: ${opts.serverMessage})` : "")
2717
+ );
2718
+ __publicField(this, "code", "PERMISSION_DENIED");
2719
+ __publicField(this, "httpStatus", 403);
2720
+ /** The server's own ADR-0112 code — the field this branch was chosen BY. */
2721
+ __publicField(this, "serverCode");
2722
+ /** The server's own message, verbatim (diagnostics only). */
2723
+ __publicField(this, "serverMessage");
2724
+ /** The dataset the refused query asked for, when it named one. */
2725
+ __publicField(this, "datasetName");
2726
+ this.name = "AnalyticsForbiddenError";
2727
+ this.serverCode = opts.serverCode;
2728
+ this.serverMessage = opts.serverMessage;
2729
+ this.datasetName = opts.datasetName;
2730
+ }
2731
+ };
1484
2732
  function readAnalyticsErrorEnvelope(body) {
1485
2733
  if (!body || typeof body !== "object") return {};
1486
2734
  const flat = body;
@@ -1503,6 +2751,30 @@ var AnalyticsQueryRejectedError = class extends Error {
1503
2751
  this.serverCode = serverCode;
1504
2752
  }
1505
2753
  };
2754
+ var AnalyticsResultShapeError = class extends Error {
2755
+ constructor(resource, result) {
2756
+ const value = result;
2757
+ const envelope = Array.isArray(value?.data?.rows);
2758
+ const received = result === null ? "null" : Array.isArray(result) ? "an array" : typeof result !== "object" ? typeof result : `an object with keys [${Object.keys(result).join(", ")}]`;
2759
+ super(
2760
+ 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).`
2761
+ );
2762
+ __publicField(this, "code", "ANALYTICS_RESULT_SHAPE_INVALID");
2763
+ /** The object `aggregate()` was called for. */
2764
+ __publicField(this, "resource");
2765
+ /**
2766
+ * True when the value is the envelope the client stopped handing back at
2767
+ * 17.3.0 — `{ success, data: { rows } }`, or its `{ data: { rows } }` core.
2768
+ */
2769
+ __publicField(this, "envelope");
2770
+ /** What the value was, so the producer is identifiable from a log. */
2771
+ __publicField(this, "received");
2772
+ this.name = "AnalyticsResultShapeError";
2773
+ this.resource = resource;
2774
+ this.envelope = envelope;
2775
+ this.received = received;
2776
+ }
2777
+ };
1506
2778
  function classifyAnalyticsFailure(error) {
1507
2779
  const err = error ?? {};
1508
2780
  const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
@@ -1514,11 +2786,11 @@ function classifyAnalyticsFailure(error) {
1514
2786
  if (errorCodeIs({ code }, "VALIDATION_FAILED")) return { kind: "rejected", code, message };
1515
2787
  if (errorCodeIs({ code }, "UNAUTHENTICATED")) return { kind: "unauthenticated", code, message };
1516
2788
  if (errorCodeIs({ code }, "CUBE_NOT_FOUND")) return { kind: "cube-not-found", code, message };
2789
+ if (status === 400) return { kind: "rejected", code, message };
1517
2790
  if (code === void 0) {
1518
2791
  if (status !== void 0 && ANALYTICS_ABSENT_STATUSES.includes(status)) {
1519
2792
  return { kind: "not-installed", code, message };
1520
2793
  }
1521
- if (status === 400) return { kind: "rejected", code, message };
1522
2794
  if (status === 401) return { kind: "unauthenticated", code, message };
1523
2795
  }
1524
2796
  return { kind: "unknown", code, message };
@@ -1530,9 +2802,22 @@ var ConcurrentUpdateError = class extends Error {
1530
2802
  __publicField(this, "httpStatus", 409);
1531
2803
  __publicField(this, "currentVersion");
1532
2804
  __publicField(this, "currentRecord");
2805
+ /**
2806
+ * The refusal text the PRODUCER marked as addressed to the end user
2807
+ * (`ApiErrorSchema.userMessage`, objectstack `79c46da90`), or `null` when the
2808
+ * refusal carried no marking.
2809
+ *
2810
+ * Declared on the class because this error has no `details` bag: the shared
2811
+ * reader (`declaredUserMessage` in `@object-ui/react`) looks at the
2812
+ * top-level field first, so parking it here is what makes the marking
2813
+ * readable at the surface. The contract is status-agnostic — a 409 carries
2814
+ * it exactly like a 400 or a 403 does.
2815
+ */
2816
+ __publicField(this, "userMessage");
1533
2817
  this.name = "ConcurrentUpdateError";
1534
2818
  this.currentVersion = opts.currentVersion;
1535
2819
  this.currentRecord = opts.currentRecord;
2820
+ this.userMessage = opts.userMessage ?? null;
1536
2821
  }
1537
2822
  };
1538
2823
  function isConcurrentUpdateError(error) {
@@ -1540,10 +2825,18 @@ function isConcurrentUpdateError(error) {
1540
2825
  const e = error;
1541
2826
  return e.code === "CONCURRENT_UPDATE" || e.name === "ConcurrentUpdateError";
1542
2827
  }
2828
+ function liftUserMessage(e, details) {
2829
+ if (typeof e.userMessage === "string" && e.userMessage.trim()) return e.userMessage;
2830
+ if (typeof details.userMessage === "string" && details.userMessage.trim()) {
2831
+ return details.userMessage;
2832
+ }
2833
+ return null;
2834
+ }
1543
2835
  function normaliseClientError(error) {
1544
2836
  if (!error || typeof error !== "object") return error;
1545
2837
  const e = error;
1546
2838
  const details = e.details ?? {};
2839
+ const userMessage = liftUserMessage(e, details);
1547
2840
  if (e.code === "VALIDATION_FAILED" || e.name === "ValidationError") {
1548
2841
  const rawFields = Array.isArray(details.fields) ? details.fields : Array.isArray(e.fields) ? e.fields : [];
1549
2842
  const validationErrors = rawFields.map((f) => {
@@ -1557,15 +2850,21 @@ function normaliseClientError(error) {
1557
2850
  typeof e.message === "string" ? e.message : "Validation failed",
1558
2851
  validationErrors[0]?.field,
1559
2852
  validationErrors,
1560
- { fields: rawFields }
2853
+ // The marking rides the details bag exactly the way `fields` already
2854
+ // does, landing on `err.details.userMessage` — the second of the two
2855
+ // places the shared reader looks. Purely additive: `field`,
2856
+ // `validationErrors` and `fields` are untouched, so the per-field
2857
+ // marking a form already draws keeps working. Omitted entirely when
2858
+ // unmarked, so an unmarked refusal carries no empty key.
2859
+ userMessage === null ? { fields: rawFields } : { fields: rawFields, userMessage }
1561
2860
  );
1562
2861
  }
1563
- if (e.code !== "CONCURRENT_UPDATE" && e.httpStatus !== 409) return error;
1564
2862
  if (e.code !== "CONCURRENT_UPDATE") return error;
1565
2863
  return new ConcurrentUpdateError({
1566
2864
  currentVersion: typeof details.currentVersion === "string" ? details.currentVersion : null,
1567
2865
  currentRecord: details.currentRecord ?? null,
1568
- message: typeof e.message === "string" ? e.message : void 0
2866
+ message: typeof e.message === "string" ? e.message : void 0,
2867
+ userMessage
1569
2868
  });
1570
2869
  }
1571
2870
  function formatHttpFailureMessage(message, meta) {
@@ -1620,6 +2919,80 @@ function createQuietHttpLogger() {
1620
2919
  };
1621
2920
  return logger;
1622
2921
  }
2922
+ var RECOGNIZED_DROP_REASONS = new Set(
2923
+ import_data4.DroppedFieldsEventSchema.shape.reason.options
2924
+ );
2925
+ var UNRECOGNIZED_DROP_REASON = "objectui:unrecognized-drop-reason";
2926
+ function isRecognizedDropReason(reason) {
2927
+ return RECOGNIZED_DROP_REASONS.has(reason);
2928
+ }
2929
+ function isWireDroppedFieldsEntry(e) {
2930
+ return !!e && typeof e === "object" && Array.isArray(e.fields) && e.fields.some((f) => typeof f === "string");
2931
+ }
2932
+ function asDroppedFieldsNotice(entry, fallbackObject) {
2933
+ const fields = entry.fields.filter((f) => typeof f === "string");
2934
+ const object = typeof entry.object === "string" ? entry.object : fallbackObject;
2935
+ if (isRecognizedDropReason(entry.reason)) {
2936
+ return { ...entry, object, fields, reason: entry.reason };
2937
+ }
2938
+ return {
2939
+ ...entry,
2940
+ object,
2941
+ fields,
2942
+ reason: UNRECOGNIZED_DROP_REASON,
2943
+ unrecognizedReason: entry.reason
2944
+ };
2945
+ }
2946
+ var UNATTRIBUTED_STRIP_OBJECT = "";
2947
+ var META_ROUTE_ABSENT_CODES = ["ROUTE_NOT_FOUND", "NOT_IMPLEMENTED"];
2948
+ var IMPORT_MAPPINGS_UNKNOWN_KIND_CODE = "INVALID_REQUEST";
2949
+ var IMPORT_MAPPINGS_UNKNOWN_KIND_STATUS = 400;
2950
+ var META_ABSENT_STATUSES = [404, 501];
2951
+ var META_REFUSAL_CODES = ["UNAUTHENTICATED", "PERMISSION_DENIED"];
2952
+ var META_REFUSAL_STATUSES = [401, 403, 405];
2953
+ function classifyImportMappingsFailure(error) {
2954
+ const err = error ?? {};
2955
+ const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
2956
+ const message = typeof err.message === "string" ? err.message : void 0;
2957
+ const status = typeof err.httpStatus === "number" ? err.httpStatus : typeof err.status === "number" ? err.status : typeof err.statusCode === "number" ? err.statusCode : void 0;
2958
+ const found = { code, status, message };
2959
+ if (errorCodeIsAnyOf({ code }, META_ROUTE_ABSENT_CODES)) {
2960
+ return { kind: "not-served", ...found };
2961
+ }
2962
+ if (errorCodeIs({ code }, IMPORT_MAPPINGS_UNKNOWN_KIND_CODE) && status === IMPORT_MAPPINGS_UNKNOWN_KIND_STATUS) {
2963
+ return { kind: "not-served", ...found };
2964
+ }
2965
+ if (errorCodeIsAnyOf({ code }, META_REFUSAL_CODES)) {
2966
+ return { kind: "refused", ...found };
2967
+ }
2968
+ if (status !== void 0 && META_REFUSAL_STATUSES.includes(status)) {
2969
+ return { kind: "refused", ...found };
2970
+ }
2971
+ if (code === void 0 && status !== void 0 && META_ABSENT_STATUSES.includes(status)) {
2972
+ return { kind: "not-served", ...found };
2973
+ }
2974
+ return { kind: "unreadable", ...found };
2975
+ }
2976
+ function classifyViewsFailure(error) {
2977
+ const err = error ?? {};
2978
+ const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
2979
+ const message = typeof err.message === "string" ? err.message : void 0;
2980
+ const status = typeof err.httpStatus === "number" ? err.httpStatus : typeof err.status === "number" ? err.status : typeof err.statusCode === "number" ? err.statusCode : void 0;
2981
+ const found = { code, status, message };
2982
+ if (errorCodeIsAnyOf({ code }, META_ROUTE_ABSENT_CODES)) {
2983
+ return { kind: "not-served", ...found };
2984
+ }
2985
+ if (errorCodeIsAnyOf({ code }, META_REFUSAL_CODES)) {
2986
+ return { kind: "refused", ...found };
2987
+ }
2988
+ if (status !== void 0 && META_REFUSAL_STATUSES.includes(status)) {
2989
+ return { kind: "refused", ...found };
2990
+ }
2991
+ if (code === void 0 && status !== void 0 && META_ABSENT_STATUSES.includes(status)) {
2992
+ return { kind: "not-served", ...found };
2993
+ }
2994
+ return { kind: "unreadable", ...found };
2995
+ }
1623
2996
  function stableStringify(value) {
1624
2997
  if (value === null || typeof value !== "object") return JSON.stringify(value);
1625
2998
  if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
@@ -1634,6 +3007,16 @@ function sameWireValue(a, b) {
1634
3007
  if (aEmpty || bEmpty) return aEmpty && bEmpty;
1635
3008
  return stableStringify(a) === stableStringify(b);
1636
3009
  }
3010
+ function readWriteWarnings(result) {
3011
+ const raw = result?.warnings;
3012
+ if (!Array.isArray(raw) || raw.length === 0) return [];
3013
+ const out = [];
3014
+ for (const entry of raw) {
3015
+ const parsed = import_api3.ValidateDataIssueSchema.safeParse(entry);
3016
+ if (parsed.success) out.push(parsed.data);
3017
+ }
3018
+ return out;
3019
+ }
1637
3020
  function withoutNoOpDrops(droppedFields, sent, stored) {
1638
3021
  if (!sent || !stored || typeof sent !== "object" || typeof stored !== "object") {
1639
3022
  return droppedFields;
@@ -1652,20 +3035,14 @@ function withoutNoOpDrops(droppedFields, sent, stored) {
1652
3035
  }
1653
3036
  return out;
1654
3037
  }
3038
+ var VIEW_ITEM_ROUND_TRIP_KEYS = Object.keys(import_ui2.VIEW_CONSOLE_ROUND_TRIP_KEYS).filter((key) => import_ui2.VIEW_CONSOLE_ROUND_TRIP_KEYS[key].includes("viewItem"));
1655
3039
  function viewItemObjectName(item) {
1656
3040
  const spec = item?.list ?? item;
1657
- return spec?.data?.object ?? spec?.object ?? spec?.objectName;
3041
+ return spec?.data?.object ?? spec?.object;
1658
3042
  }
1659
3043
  var VIEW_OVERLAY_MARKER = "_isOverride";
1660
- function isLegacyOverlayRow(item, spec) {
1661
- if (spec && spec.config && typeof spec.config === "object") return false;
1662
- const viewKind = item?.viewKind ?? spec?.viewKind;
1663
- return viewKind === "list";
1664
- }
1665
3044
  function isPersonalizationOverlayRow(item, spec) {
1666
- if (item?.[VIEW_OVERLAY_MARKER] === true) return true;
1667
- if (spec?.[VIEW_OVERLAY_MARKER] === true) return true;
1668
- return isLegacyOverlayRow(item, spec);
3045
+ return item?.[VIEW_OVERLAY_MARKER] === true || spec?.[VIEW_OVERLAY_MARKER] === true;
1669
3046
  }
1670
3047
  var VIEW_OVERLAY_OWNED_KEYS = Object.freeze([
1671
3048
  "rowHeight",
@@ -1697,8 +3074,9 @@ function narrowPersonalizationOverlay(row) {
1697
3074
  function unwrapViewDraft(resp) {
1698
3075
  if (!resp || typeof resp !== "object") return null;
1699
3076
  const env = resp;
1700
- const body = "item" in env ? env.item : env;
1701
- if (!body || typeof body !== "object") return null;
3077
+ const raw = "item" in env ? env.item : env;
3078
+ if (!raw || typeof raw !== "object") return null;
3079
+ const body = (0, import_kernel2.stripReadDecorations)(raw);
1702
3080
  const spec = body.list ?? body;
1703
3081
  if (!spec || typeof spec !== "object") return null;
1704
3082
  return Object.keys(spec).length > 0 ? spec : null;
@@ -1719,6 +3097,10 @@ function mergeViewPatch(current, partial, viewName, objectName) {
1719
3097
  object: current?.object || current?.data?.object || objectName
1720
3098
  };
1721
3099
  }
3100
+ function isCallerAbort(error, init) {
3101
+ if (init?.signal?.aborted) return true;
3102
+ return error?.name === "AbortError";
3103
+ }
1722
3104
  var ObjectStackAdapter = class {
1723
3105
  constructor(config) {
1724
3106
  __publicField(this, "client");
@@ -1742,6 +3124,13 @@ var ObjectStackAdapter = class {
1742
3124
  // multiple sibling components requesting the same dataset on first paint)
1743
3125
  // into a single network round trip.
1744
3126
  __publicField(this, "inflightFinds", /* @__PURE__ */ new Map());
3127
+ // In-flight findOne() reads keyed by resource + record id + serialized
3128
+ // params, shared the same way (objectui#11699). Opening one record page
3129
+ // asked for the same record twice at once: `record:details`' DetailView
3130
+ // re-runs its load effect while its first read is still on the wire. A
3131
+ // write to a resource drops that resource's entries (see `emitMutation`),
3132
+ // so a read asked after a save is never answered by one sent before it.
3133
+ __publicField(this, "inflightFindOnes", /* @__PURE__ */ new Map());
1745
3134
  // Resources that have responded 404 at least once (collection not installed
1746
3135
  // on this backend). Subsequent find() calls short-circuit to an empty result
1747
3136
  // so optional collections like sys_presence don't hammer the server with
@@ -1754,10 +3143,10 @@ var ObjectStackAdapter = class {
1754
3143
  // one adapter that has to cope with a backend lacking server atomicity (#2679).
1755
3144
  __publicField(this, "batchUnsupported", false);
1756
3145
  // The server's declared cross-object atomic-batch capability, read from
1757
- // discovery at connect() (framework #3298 / objectui #2693). `true` → the
3146
+ // discovery at connect() (objectstack-ai/objectstack#3298 / objectui #2693). `true` → the
1758
3147
  // backend GUARANTEES an atomic `/batch`, so batchTransaction trusts it and
1759
3148
  // never degrades to the non-atomic emulation (any failure surfaces as a real
1760
- // error). `false` or `undefined` (capability absent → backend predates #3298)
3149
+ // error). `false` or `undefined` (capability absent → backend predates objectstack-ai/objectstack#3298)
1761
3150
  // → keep the legacy runtime-probe + emulation fallback so a save is still
1762
3151
  // possible; dropping it there would turn "saves, less safe" into "no save
1763
3152
  // path" on older backends (#2679 compatibility constraint).
@@ -1768,7 +3157,7 @@ var ObjectStackAdapter = class {
1768
3157
  // inline-edit "Save All" writes without a manual reload.
1769
3158
  __publicField(this, "mutationListeners", /* @__PURE__ */ new Set());
1770
3159
  // Subscribers registered via onWriteWarning(). Emitted after a create/update
1771
- // whose response carried `droppedFields` (framework #3431/#3455) so the app
3160
+ // whose response carried `droppedFields` (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455) so the app
1772
3161
  // shell can surface a toast instead of the strip passing silently.
1773
3162
  __publicField(this, "writeWarningListeners", /* @__PURE__ */ new Set());
1774
3163
  // Subscribers registered via onSaveAdvisory(). Emitted after a metadata save
@@ -1777,62 +3166,85 @@ var ObjectStackAdapter = class {
1777
3166
  // above in every respect except which door produced the event: that one is
1778
3167
  // record CRUD, this one is the metadata save door.
1779
3168
  __publicField(this, "saveAdvisoryListeners", /* @__PURE__ */ new Set());
1780
- this.client = new import_client.ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
3169
+ // Subscribers registered via onMetadataReadWarning(). Emitted when a metadata
3170
+ // READ failed in a way that is NOT the supported "this deployment does not
3171
+ // serve that kind" shape, and was degraded to an empty result anyway
3172
+ // (objectui#7741). Third sibling of the two sets above: same seam shape, and
3173
+ // the opposite direction — those two describe a write that succeeded, this
3174
+ // one a read that did not happen.
3175
+ __publicField(this, "metadataReadWarningListeners", /* @__PURE__ */ new Set());
3176
+ const transport = config.fetch || globalThis.fetch.bind(globalThis);
3177
+ this.fetchImpl = (input, init) => this.observeServerAnswer(() => transport(input, init), init);
3178
+ this.client = new import_client.ObjectStackClient({
3179
+ ...config,
3180
+ fetch: this.fetchImpl,
3181
+ logger: createQuietHttpLogger()
3182
+ });
1781
3183
  this.installSaveAdvisoryInterceptor();
1782
3184
  this.metadataCache = new MetadataCache(config.cache);
3185
+ this.installViewListShare();
1783
3186
  this.autoReconnect = config.autoReconnect ?? true;
1784
3187
  this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3;
1785
3188
  this.reconnectDelay = config.reconnectDelay ?? 1e3;
1786
3189
  this.baseUrl = config.baseUrl;
1787
3190
  this.token = config.token;
1788
- this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
1789
3191
  }
1790
3192
  /**
1791
3193
  * Ensure the client is connected to the server.
1792
3194
  * Call this before making requests or it will auto-connect on first request.
3195
+ *
3196
+ * With `autoReconnect`, a failed discovery read is retried up to
3197
+ * `maxReconnectAttempts` times with exponential backoff. The promise resolves
3198
+ * once discovery answers and rejects with the last `ConnectionError` once the
3199
+ * retries are spent (objectui#12318).
1793
3200
  */
1794
3201
  async connect() {
1795
3202
  if (this.connected) return;
1796
3203
  if (this.connectPromise) return this.connectPromise;
1797
3204
  this.setConnectionState("connecting");
1798
- this.connectPromise = (async () => {
1799
- try {
1800
- const baseUrl = this.baseUrl || "";
1801
- const discoveryUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/api/v1/discovery` : "/api/v1/discovery";
1802
- const data = await getSharedDiscovery(baseUrl, async () => {
1803
- const res = await this.fetchImpl(discoveryUrl, {
1804
- method: "GET",
1805
- headers: this.token ? { Authorization: `Bearer ${this.token}` } : void 0
1806
- });
1807
- if (!res.ok) {
1808
- throw new Error(`discovery ${res.status} ${res.statusText}`);
1809
- }
1810
- const body = await res.json();
1811
- return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
3205
+ this.connectPromise = this.attemptConnect().finally(() => {
3206
+ this.connectPromise = null;
3207
+ });
3208
+ return this.connectPromise;
3209
+ }
3210
+ /**
3211
+ * One discovery attempt, and the retry that follows it when it fails with
3212
+ * budget left. Runs inside the promise {@link connect} hands every caller.
3213
+ */
3214
+ async attemptConnect() {
3215
+ try {
3216
+ const baseUrl = this.baseUrl || "";
3217
+ const discoveryUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/api/v1/discovery` : "/api/v1/discovery";
3218
+ const data = await getSharedDiscovery(baseUrl, async () => {
3219
+ const res = await this.fetchImpl(discoveryUrl, {
3220
+ method: "GET",
3221
+ headers: this.token ? { Authorization: `Bearer ${this.token}` } : void 0
1812
3222
  });
1813
- this.client.discoveryInfo = data;
1814
- this.atomicBatchCapability = readTransactionalBatchCapability(data);
1815
- this.connected = true;
1816
- this.reconnectAttempts = 0;
1817
- this.setConnectionState("connected");
1818
- } catch (error) {
1819
- const errorMessage = error instanceof Error ? error.message : "Failed to connect to ObjectStack server";
1820
- const connectionError = new ConnectionError(
1821
- errorMessage,
1822
- void 0,
1823
- { originalError: error }
1824
- );
1825
- this.setConnectionState("error", connectionError);
1826
- if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
1827
- await this.attemptReconnect();
1828
- } else {
1829
- throw connectionError;
3223
+ if (!res.ok) {
3224
+ throw new Error(`discovery ${res.status} ${res.statusText}`);
1830
3225
  }
1831
- } finally {
1832
- this.connectPromise = null;
3226
+ const body = await res.json();
3227
+ return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
3228
+ });
3229
+ this.client.discoveryInfo = data;
3230
+ this.atomicBatchCapability = readTransactionalBatchCapability(data);
3231
+ this.connected = true;
3232
+ this.reconnectAttempts = 0;
3233
+ this.setConnectionState("connected");
3234
+ } catch (error) {
3235
+ const errorMessage = error instanceof Error ? error.message : "Failed to connect to ObjectStack server";
3236
+ const connectionError = new ConnectionError(
3237
+ errorMessage,
3238
+ void 0,
3239
+ { originalError: error }
3240
+ );
3241
+ this.setConnectionState("error", connectionError);
3242
+ if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
3243
+ await this.attemptReconnect();
3244
+ } else {
3245
+ throw connectionError;
1833
3246
  }
1834
- })();
1835
- return this.connectPromise;
3247
+ }
1836
3248
  }
1837
3249
  /**
1838
3250
  * Attempt to reconnect to the server with exponential backoff
@@ -1842,8 +3254,7 @@ var ObjectStackAdapter = class {
1842
3254
  this.setConnectionState("reconnecting");
1843
3255
  const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
1844
3256
  await new Promise((resolve) => setTimeout(resolve, delay));
1845
- this.connected = false;
1846
- await this.connect();
3257
+ await this.attemptConnect();
1847
3258
  }
1848
3259
  /**
1849
3260
  * Get the current connection state
@@ -1899,6 +3310,63 @@ var ObjectStackAdapter = class {
1899
3310
  }
1900
3311
  });
1901
3312
  }
3313
+ /**
3314
+ * Run one request through the adapter's fetch and let its outcome move the
3315
+ * connection state (objectui#12295). Before the first successful
3316
+ * {@link connect} this records nothing: `connect()` owns the state until
3317
+ * then, and its own discovery read passes through here too.
3318
+ *
3319
+ * - **Answered** — the fetch resolved with a Response, whatever its status.
3320
+ * A 4xx or 5xx is an answer: the server is reachable, so it counts the
3321
+ * same as a 200 and ends any outage.
3322
+ * - **Not answered** — the fetch rejected: no HTTP answer at all (refused,
3323
+ * reset, DNS, an offline browser). A rejection the caller asked for by
3324
+ * aborting is not evidence about the server and records nothing.
3325
+ *
3326
+ * The rejection or the Response reaches the caller unchanged.
3327
+ */
3328
+ async observeServerAnswer(send, init) {
3329
+ let response;
3330
+ try {
3331
+ response = await send();
3332
+ } catch (error) {
3333
+ if (!isCallerAbort(error, init)) this.noteServerUnanswered(error);
3334
+ throw error;
3335
+ }
3336
+ this.noteServerAnswered();
3337
+ return response;
3338
+ }
3339
+ /** An answered request after the first connect: the server is reachable. */
3340
+ noteServerAnswered() {
3341
+ if (!this.connected) return;
3342
+ this.reconnectAttempts = 0;
3343
+ if (this.connectionState !== "connected") this.setConnectionState("connected");
3344
+ }
3345
+ /**
3346
+ * An unanswered request after the first connect. It spends the adapter's own
3347
+ * retry window — the `autoReconnect` / `maxReconnectAttempts` budget that
3348
+ * {@link connect} grants a failed discovery — counted in the requests the
3349
+ * host already makes rather than in timed probes: while the budget lasts the
3350
+ * state is `reconnecting`, and the first unanswered request past it moves the
3351
+ * state to `error`. No timer and no request of the adapter's own is involved;
3352
+ * the next answered request, whenever it comes, restores `connected`.
3353
+ */
3354
+ noteServerUnanswered(error) {
3355
+ if (!this.connected || this.connectionState === "error") return;
3356
+ if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
3357
+ this.reconnectAttempts++;
3358
+ if (this.connectionState !== "reconnecting") this.setConnectionState("reconnecting");
3359
+ return;
3360
+ }
3361
+ this.setConnectionState(
3362
+ "error",
3363
+ new ConnectionError(
3364
+ error instanceof Error ? error.message : "ObjectStack server did not answer",
3365
+ void 0,
3366
+ { originalError: error }
3367
+ )
3368
+ );
3369
+ }
1902
3370
  /**
1903
3371
  * Emit batch progress event to listeners
1904
3372
  */
@@ -1920,9 +3388,7 @@ var ObjectStackAdapter = class {
1920
3388
  return { data: [], total: 0 };
1921
3389
  }
1922
3390
  const key = `${resource}::${stableStringify(params)}`;
1923
- const existing = this.inflightFinds.get(key);
1924
- if (existing) return existing;
1925
- const promise = (async () => {
3391
+ return this.shareInFlight(this.inflightFinds, key, async () => {
1926
3392
  await this.connect();
1927
3393
  if (params?.$expand && params.$expand.length > 0 || params?.$search != null && String(params.$search).trim() !== "") {
1928
3394
  const result = await this.rawFindWithPopulate(resource, params);
@@ -1939,11 +3405,25 @@ var ObjectStackAdapter = class {
1939
3405
  }
1940
3406
  throw err;
1941
3407
  }
1942
- })();
1943
- this.inflightFinds.set(key, promise);
3408
+ });
3409
+ }
3410
+ /**
3411
+ * Share one in-flight read among concurrent callers asking the same
3412
+ * question: the first call for `key` issues `read()`, every call that
3413
+ * arrives while it is still pending gets that same promise, and the entry is
3414
+ * dropped the moment it settles — so nothing is cached past the round trip,
3415
+ * a rejection reaches every caller that shared it and is not remembered, and
3416
+ * the next call after settle issues a fresh read. Used by `find` and
3417
+ * `findOne` (objectui#11699).
3418
+ */
3419
+ shareInFlight(inflight, key, read) {
3420
+ const existing = inflight.get(key);
3421
+ if (existing) return existing;
3422
+ const promise = read();
3423
+ inflight.set(key, promise);
1944
3424
  const cleanup = () => {
1945
- if (this.inflightFinds.get(key) === promise) {
1946
- this.inflightFinds.delete(key);
3425
+ if (inflight.get(key) === promise) {
3426
+ inflight.delete(key);
1947
3427
  }
1948
3428
  };
1949
3429
  promise.then(cleanup, cleanup);
@@ -1958,7 +3438,7 @@ var ObjectStackAdapter = class {
1958
3438
  * — a per-object metadata-driven search (ADR-0061) — this consults the search
1959
3439
  * index and ranks hits across objects, so it surfaces records the per-object
1960
3440
  * fanout misses. Global affordances (⌘K command palette, search page) prefer
1961
- * this path (framework #3371).
3441
+ * this path (objectstack-ai/objectstack#3371).
1962
3442
  *
1963
3443
  * Returns `{ query, hits }`. A backend without the search plugin installed
1964
3444
  * answers `404`; we treat that as "no global search here" and return an empty
@@ -1991,10 +3471,10 @@ var ObjectStackAdapter = class {
1991
3471
  if (!res.ok) {
1992
3472
  if (res.status === 404) return { query: trimmed, hits: [] };
1993
3473
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
1994
- const err = new Error(
1995
- errorBody?.error?.message || errorBody?.message || res.statusText
1996
- );
3474
+ const envelope = readErrorEnvelope(errorBody);
3475
+ const err = new Error(envelope.message ?? res.statusText);
1997
3476
  err.status = res.status;
3477
+ err.code = envelope.code;
1998
3478
  throw err;
1999
3479
  }
2000
3480
  const body = await res.json();
@@ -2022,8 +3502,16 @@ var ObjectStackAdapter = class {
2022
3502
  }
2023
3503
  /**
2024
3504
  * Find a single record by ID.
3505
+ *
3506
+ * Concurrent calls for the same resource, id and params share one request,
3507
+ * exactly as `find` does (objectui#11699); see {@link shareInFlight}.
2025
3508
  */
2026
3509
  async findOne(resource, id, params) {
3510
+ const key = `${resource}::${JSON.stringify(String(id))}::${stableStringify(params)}`;
3511
+ return this.shareInFlight(this.inflightFindOnes, key, () => this.readOne(resource, id, params));
3512
+ }
3513
+ /** The one round trip behind {@link findOne}, unshared. */
3514
+ async readOne(resource, id, params) {
2027
3515
  await this.connect();
2028
3516
  if (params?.$expand && params.$expand.length > 0) {
2029
3517
  try {
@@ -2063,6 +3551,10 @@ var ObjectStackAdapter = class {
2063
3551
  * mutation or starve the other subscribers, so each is isolated.
2064
3552
  */
2065
3553
  emitMutation(event) {
3554
+ const prefix = `${event.resource}::`;
3555
+ for (const key of Array.from(this.inflightFindOnes.keys())) {
3556
+ if (key.startsWith(prefix)) this.inflightFindOnes.delete(key);
3557
+ }
2066
3558
  for (const listener of this.mutationListeners) {
2067
3559
  try {
2068
3560
  listener(event);
@@ -2097,24 +3589,41 @@ var ObjectStackAdapter = class {
2097
3589
  }
2098
3590
  }
2099
3591
  /**
2100
- * Read `droppedFields` off a create/update response (framework #3431/#3455)
3592
+ * Read `droppedFields` off a create/update response (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455)
2101
3593
  * and, when present, notify write-warning subscribers. Tolerant of a client
2102
3594
  * whose response type predates `droppedFields`: the field is read structurally
2103
3595
  * and validated, so an older client (or a backend that never drops) is a no-op.
3596
+ *
3597
+ * SHAPE decides whether an entry is an event at all (it must name at least one
3598
+ * field); `object`, `fields` and `reason` are then PARSED — an unrecognized
3599
+ * reason routed to the skew arm, a non-string field element refused, a missing
3600
+ * `object` healed from `resource` — never asserted into the union, and the
3601
+ * entry itself never dropped for them (objectui#4934, `f75810e7c`).
3602
+ *
3603
+ * The same answer's `warnings` (objectui#12107) rides this one event: it is
3604
+ * emitted when either list is non-empty, so a write whose only news is an
3605
+ * advisory rule hit still reaches the subscriber, with `droppedFields: []`.
2104
3606
  */
2105
3607
  notifyDroppedFields(operation, resource, result, id, sent) {
2106
3608
  const dropped = result?.droppedFields;
2107
- if (!Array.isArray(dropped) || dropped.length === 0) return;
2108
- const valid = dropped.filter(
2109
- (e) => !!e && typeof e === "object" && Array.isArray(e.fields) && e.fields.length > 0
2110
- );
2111
- const stored = result?.record;
2112
- const droppedFields = withoutNoOpDrops(valid, sent, stored);
2113
- if (droppedFields.length === 0) return;
2114
- this.emitWriteWarning({ operation, resource, ...id !== void 0 ? { id } : {}, droppedFields });
3609
+ let droppedFields = [];
3610
+ if (Array.isArray(dropped) && dropped.length > 0) {
3611
+ const valid = dropped.filter(isWireDroppedFieldsEntry).map((e) => asDroppedFieldsNotice(e, resource));
3612
+ const stored = result?.record;
3613
+ droppedFields = withoutNoOpDrops(valid, sent, stored);
3614
+ }
3615
+ const warnings = readWriteWarnings(result);
3616
+ if (droppedFields.length === 0 && warnings.length === 0) return;
3617
+ this.emitWriteWarning({
3618
+ operation,
3619
+ resource,
3620
+ ...id !== void 0 ? { id } : {},
3621
+ droppedFields,
3622
+ ...warnings.length > 0 ? { warnings } : {}
3623
+ });
2115
3624
  }
2116
3625
  /**
2117
- * Same, for the cross-object transactional batch (framework #3794). Its
3626
+ * Same, for the cross-object transactional batch (objectstack-ai/objectstack#3794). Its
2118
3627
  * response hangs the events off a top-level `droppedFields` list, each tagged
2119
3628
  * with the `index` of the operation it came from — `results` entries are bare
2120
3629
  * record echoes with nowhere to hang a per-row list.
@@ -2130,13 +3639,13 @@ var ObjectStackAdapter = class {
2130
3639
  if (!Array.isArray(dropped) || dropped.length === 0) return;
2131
3640
  const results = payload?.results;
2132
3641
  for (const entry of dropped) {
2133
- if (!entry || typeof entry !== "object") continue;
3642
+ if (!isWireDroppedFieldsEntry(entry)) continue;
2134
3643
  const e = entry;
2135
- if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
2136
3644
  const op = typeof e.index === "number" ? operations[e.index] : void 0;
3645
+ const object = typeof e.object === "string" ? e.object : typeof op?.object === "string" ? op.object : UNATTRIBUTED_STRIP_OBJECT;
2137
3646
  const stored = typeof e.index === "number" && Array.isArray(results) ? results[e.index] : void 0;
2138
3647
  const [live] = withoutNoOpDrops(
2139
- [{ object: e.object, fields: e.fields, reason: e.reason }],
3648
+ [asDroppedFieldsNotice({ fields: e.fields, reason: e.reason }, object)],
2140
3649
  op?.data,
2141
3650
  stored
2142
3651
  );
@@ -2144,7 +3653,7 @@ var ObjectStackAdapter = class {
2144
3653
  const operation = (op?.action ?? "create") === "create" ? "create" : "update";
2145
3654
  this.emitWriteWarning({
2146
3655
  operation,
2147
- resource: e.object ?? op?.object ?? "",
3656
+ resource: object,
2148
3657
  ...op?.id !== void 0 && op?.id !== null ? { id: op.id } : {},
2149
3658
  droppedFields: [live]
2150
3659
  });
@@ -2152,7 +3661,7 @@ var ObjectStackAdapter = class {
2152
3661
  }
2153
3662
  /**
2154
3663
  * Subscribe to write-warning events (a create/update dropped caller-supplied
2155
- * fields — #3431/#3455). Returns an unsubscribe function. The app shell uses
3664
+ * fields — objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455). Returns an unsubscribe function. The app shell uses
2156
3665
  * this to toast the user; the write itself already succeeded.
2157
3666
  */
2158
3667
  onWriteWarning(callback) {
@@ -2166,7 +3675,7 @@ var ObjectStackAdapter = class {
2166
3675
  * advisory findings on a save that SUCCEEDED (#4237; backend
2167
3676
  * objectstack#7435). Returns an unsubscribe function.
2168
3677
  *
2169
- * Deliberately the same seam as {@link onWriteWarning} (#3431/#3455), which
3678
+ * Deliberately the same seam as {@link onWriteWarning} (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455), which
2170
3679
  * is what {@link MetadataSaveAdvisoryEvent}'s own declaration already said it
2171
3680
  * was modelled on. It is a SIBLING of that channel rather than a second
2172
3681
  * payload pushed down it: `WriteWarningEvent` is a closed shape whose
@@ -2203,6 +3712,45 @@ var ObjectStackAdapter = class {
2203
3712
  }
2204
3713
  }
2205
3714
  }
3715
+ /**
3716
+ * Subscribe to metadata read-warning events — a metadata READ that could not
3717
+ * be answered and was degraded to an empty result anyway (objectui#7741).
3718
+ * Returns an unsubscribe function. The app shell uses this to toast the user;
3719
+ * the caller has already been handed the empty result.
3720
+ *
3721
+ * Deliberately the same seam as {@link onWriteWarning} and
3722
+ * {@link onSaveAdvisory}, and a SIBLING of them rather than a payload pushed
3723
+ * down either: both of those announce a write that SUCCEEDED, and an event
3724
+ * saying "this read did not happen" carried on one of them would make it lie
3725
+ * about what occurred. What is reused is the seam's SHAPE — one long-lived
3726
+ * instance with a `subscribe -> unsubscribe` registration that
3727
+ * `AdapterProvider` wires once — not its event type.
3728
+ *
3729
+ * ⛔ Nothing here changes what a caller RECEIVES. `listImportMappings` still
3730
+ * answers `[]` on every failure, including the loud ones; this channel is
3731
+ * added ALONGSIDE that return, which is why it is not a change to a surface
3732
+ * published since `@object-ui/data-objectstack@17.1.0`.
3733
+ */
3734
+ onMetadataReadWarning(callback) {
3735
+ this.metadataReadWarningListeners.add(callback);
3736
+ return () => {
3737
+ this.metadataReadWarningListeners.delete(callback);
3738
+ };
3739
+ }
3740
+ /**
3741
+ * Notify all metadata read-warning subscribers. Isolated exactly like
3742
+ * {@link emitWriteWarning}: a throwing listener must neither break the caller
3743
+ * nor starve the others.
3744
+ */
3745
+ emitMetadataReadWarning(event) {
3746
+ for (const listener of this.metadataReadWarningListeners) {
3747
+ try {
3748
+ listener(event);
3749
+ } catch (err) {
3750
+ console.warn("ObjectStackAdapter: metadata read-warning listener error", err);
3751
+ }
3752
+ }
3753
+ }
2206
3754
  /**
2207
3755
  * Install the ONE emitter for the metadata save door (#4237).
2208
3756
  *
@@ -2210,8 +3758,8 @@ var ObjectStackAdapter = class {
2210
3758
  *
2211
3759
  * `ObjectStackClient.meta.saveItem` is the second client class that writes
2212
3760
  * through `PUT /api/v1/meta/:type/:name`, and every one of its callers reaches
2213
- * it through an adapter this class constructed — the four inside this file
2214
- * (`updateViewConfig`, the two view paths, `updateDashboard`) via
3761
+ * it through an adapter this class constructed — the three inside this file
3762
+ * (the two view paths, `updateDashboard`) via
2215
3763
  * `this.client`, and every caller outside it via {@link getClient}, which
2216
3764
  * hands back this same instance: `MetadataService` (app-shell, five saves),
2217
3765
  * `useNavigationSync`, and plugin-designer's Create/EditAppPage. Wrapping the
@@ -2240,26 +3788,42 @@ var ObjectStackAdapter = class {
2240
3788
  *
2241
3789
  * Drafts are NEVER gated: the framework returns at its D1 early-return
2242
3790
  * (`if (args.state !== 'active') return null`) before running a rule, so a
2243
- * draft save produces no findings to withhold. This client class has no draft
2244
- * door at all to worry about — the SDK's `saveItem(type, name, item)` takes no
2245
- * mode and always writes the active door, which is exactly why the gate DOES
2246
- * run for its callers. `mode` on the emitted event is therefore derived from
2247
- * the response's own `state` rather than from a request-side flag that does
2248
- * not exist here: `'draft'` when the server says the row landed as a draft,
2249
- * `'publish'` otherwise. That keeps the event truthful about which door it
2250
- * came through instead of hard-coding one.
3791
+ * draft save produces no findings to withhold. `mode` on the emitted event is
3792
+ * derived from the response's own `state`, not from the caller's options:
3793
+ * `'draft'` when the server says the row landed as a draft, `'publish'`
3794
+ * otherwise. The server's answer is the one that says which door the write
3795
+ * took, so the event cannot claim a door the request only asked for.
3796
+ *
3797
+ * ## Every argument is forwarded (objectui#12300)
3798
+ *
3799
+ * The SDK's `saveItem(type, name, item, options?)` carries the write's scope
3800
+ * in its fourth argument, `SaveMetaItemOptions`: `packageId` (`?package=`),
3801
+ * `force`, `mode` and `ifMatch`. This wrapper used to name three parameters
3802
+ * and call the original with three, so that bag was dropped before the SDK
3803
+ * saw it. Every caller reaching the SDK through {@link getClient} got a bare
3804
+ * `PUT` whatever it passed, while the declared signature still accepted the
3805
+ * bag, so nothing at the call site could tell. The wrapper now takes and
3806
+ * passes the original's own parameter list, so it forwards whatever the SDK
3807
+ * declares, today's bag and any later member, without naming one.
2251
3808
  */
2252
3809
  installSaveAdvisoryInterceptor() {
2253
3810
  const meta = this.client.meta;
2254
3811
  const original = meta.saveItem.bind(meta);
2255
- meta.saveItem = async (type, name, item) => {
2256
- const result = await original(type, name, item);
3812
+ meta.saveItem = async (...args) => {
3813
+ const [type, name] = args;
3814
+ const result = await original(...args);
2257
3815
  try {
2258
3816
  const advisories = readSaveAdvisories(result);
2259
3817
  if (advisories.length > 0) {
2260
3818
  this.emitSaveAdvisory({
2261
3819
  type,
2262
3820
  name,
3821
+ // #5026 — this interceptor wraps `meta.saveItem`, the SAVE door
3822
+ // (`PUT /meta/:type/:name`) and only that one. The SDK's publish
3823
+ // door (`meta.publishItem`) has no caller in this repo, so wiring
3824
+ // it here would be a surface with no consumer; `MetadataClient` is
3825
+ // where the publish door is actually taken.
3826
+ door: "save",
2263
3827
  mode: result?.state === "draft" ? "draft" : "publish",
2264
3828
  advisories
2265
3829
  });
@@ -2270,6 +3834,61 @@ var ObjectStackAdapter = class {
2270
3834
  return result;
2271
3835
  };
2272
3836
  }
3837
+ /**
3838
+ * Route this adapter's client's published view-list read through the one
3839
+ * {@link VIEW_LIST_KEY} (objectui#13044).
3840
+ *
3841
+ * The app-shell `MetadataProvider` reads `client.meta.getItems('view')`
3842
+ * through {@link getClient}; {@link listViews} and {@link listViewOverrides}
3843
+ * read the same `GET /api/v1/meta/view`. Each used to put its own request on
3844
+ * the wire, so one list-page open asked the server three times for one URL.
3845
+ *
3846
+ * A caller of the client keeps its contract, "ask the server": it is never
3847
+ * answered from a settled entry. It joins a request already in flight for
3848
+ * the same URL instead of sending a second one, and its answer is stored
3849
+ * under {@link VIEW_LIST_KEY} (see {@link MetadataCache.share}), which is
3850
+ * what {@link readViewList} answers this adapter's own readers from
3851
+ * (objectui#13057). Only the bare call is routed: any argument after the
3852
+ * type changes the URL, so such a call goes straight to the SDK.
3853
+ *
3854
+ * Every caller gets its own copy of the answer. Before this, every reader
3855
+ * got its own parsed response, so a reader that changed what it was handed
3856
+ * changed nothing anyone else read; a shared answer must not change that.
3857
+ *
3858
+ * Same wrap shape and the same bound as `installSaveAdvisoryInterceptor`:
3859
+ * `meta` is an own property of a client this adapter built and never shares.
3860
+ */
3861
+ installViewListShare() {
3862
+ const meta = this.client.meta;
3863
+ const original = meta.getItems.bind(meta);
3864
+ meta.getItems = ((...args) => {
3865
+ if (args[0] !== "view" || args.length > 1) return original(...args);
3866
+ return this.metadataCache.share(VIEW_LIST_KEY, () => original(...args)).then((answer) => structuredClone(answer));
3867
+ });
3868
+ for (const door of ["saveItem", "deleteItem", "publishItem", "rollbackItem"]) {
3869
+ const write = meta[door];
3870
+ if (typeof write !== "function") continue;
3871
+ const bound = write.bind(meta);
3872
+ meta[door] = (...args) => {
3873
+ if (args[0] !== "view") return bound(...args);
3874
+ return bound(...args).finally(() => this.metadataCache.invalidate?.(VIEW_LIST_KEY));
3875
+ };
3876
+ }
3877
+ }
3878
+ /**
3879
+ * The published view list for this adapter's own readers
3880
+ * ({@link listViews}, {@link listViewOverrides}): the answer stored under
3881
+ * {@link VIEW_LIST_KEY} while it holds one (objectui#13057), else the
3882
+ * client's read, which joins the request in flight for that key or starts
3883
+ * one, and stores its answer (objectui#13044). Always a copy the caller may
3884
+ * keep. Every path that should see a fresh list drops the key first; the
3885
+ * list is in {@link VIEW_LIST_KEY}'s doc.
3886
+ */
3887
+ readViewList() {
3888
+ const settled = this.metadataCache.getCachedSync(VIEW_LIST_KEY);
3889
+ if (settled !== void 0) return Promise.resolve(structuredClone(settled));
3890
+ return this.client.meta.getItems("view");
3891
+ }
2273
3892
  async create(resource, data) {
2274
3893
  await this.connect();
2275
3894
  try {
@@ -2307,7 +3926,7 @@ var ObjectStackAdapter = class {
2307
3926
  data,
2308
3927
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
2309
3928
  );
2310
- this.emitMutation({ type: "update", resource, id, record: { ...result.record } });
3929
+ this.emitMutation({ type: "update", resource, id: String(id), record: { ...result.record } });
2311
3930
  this.notifyDroppedFields("update", resource, result, id, data);
2312
3931
  return result.record;
2313
3932
  } catch (err) {
@@ -2330,7 +3949,7 @@ var ObjectStackAdapter = class {
2330
3949
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
2331
3950
  );
2332
3951
  if (result.success) {
2333
- this.emitMutation({ type: "delete", resource, id });
3952
+ this.emitMutation({ type: "delete", resource, id: String(id) });
2334
3953
  }
2335
3954
  return result.success;
2336
3955
  } catch (err) {
@@ -2431,25 +4050,25 @@ var ObjectStackAdapter = class {
2431
4050
  * @returns Promise resolving to array of results
2432
4051
  */
2433
4052
  /**
2434
- * Cross-object transactional batch (ObjectStack #1604 / ADR-0034 item 4).
4053
+ * Cross-object transactional batch (objectstack-ai/objectstack#1604 / ADR-0034 item 4).
2435
4054
  * Runs the operations in ONE server transaction — commit all or roll back
2436
4055
  * all. A field value of `{ $ref: <earlier op index> }` resolves to that op's
2437
4056
  * created id, so a child can reference its parent created earlier in the same
2438
4057
  * batch (master-detail).
2439
4058
  *
2440
4059
  * Transport: the published `@objectstack/client` SDK method
2441
- * `data.batchTransaction` (framework #3271; shipped since client v16, our
4060
+ * `data.batchTransaction` (objectstack-ai/objectstack#3271; shipped since client v16, our
2442
4061
  * dependency floor). Per AGENTS.md §7 data always flows through the client —
2443
4062
  * never a hand-rolled `fetch('/api/v1/batch')`.
2444
4063
  *
2445
- * Fallback decision — declarative capability negotiation (framework #3298 /
4064
+ * Fallback decision — declarative capability negotiation (objectstack-ai/objectstack#3298 /
2446
4065
  * objectui #2693). At connect() we read `capabilities.transactionalBatch`
2447
4066
  * from discovery:
2448
4067
  * - Declared `true` → the backend GUARANTEES atomicity (declared ===
2449
4068
  * enforced). We TRUST it: any batch failure — including 404/405/501 —
2450
4069
  * surfaces as a real error. No non-atomic client-side compensation. This
2451
4070
  * is the path modern backends take.
2452
- * - Declared `false`, or ABSENT (backend predates #3298) → we can't rely on
4071
+ * - Declared `false`, or ABSENT (backend predates objectstack-ai/objectstack#3298) → we can't rely on
2453
4072
  * server atomicity, so we keep the legacy behaviour: on 404/405 (no
2454
4073
  * endpoint) or 501 (runtime without transactions) degrade to the
2455
4074
  * client-side, NON-atomic {@link emulateBatchTransaction} so a save is
@@ -2873,9 +4492,10 @@ var ObjectStackAdapter = class {
2873
4492
  const res = await this.fetchImpl(url, { method: "GET", headers });
2874
4493
  if (!res.ok) {
2875
4494
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
2876
- const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
4495
+ const envelope = readErrorEnvelope(errorBody);
4496
+ const err = new Error(envelope.message ?? res.statusText);
2877
4497
  err.status = res.status;
2878
- err.code = errorBody?.code ?? errorBody?.error?.code;
4498
+ err.code = envelope.code;
2879
4499
  err.httpStatus = res.status;
2880
4500
  throw err;
2881
4501
  }
@@ -2925,16 +4545,51 @@ var ObjectStackAdapter = class {
2925
4545
  queryParams.set("searchFields", request.searchFields.join(","));
2926
4546
  }
2927
4547
  }
4548
+ return this.fetchExportBlob(resource, queryParams);
4549
+ }
4550
+ /**
4551
+ * Download the server's import template:
4552
+ * `GET /api/v1/data/:object/export?template=true` (objectui#9600).
4553
+ *
4554
+ * Same route as {@link exportDownload}, with the server's template switch. It
4555
+ * sends only `template=true`: the server refuses the export's row parameters
4556
+ * on a template request, and its answer is always an xlsx workbook.
4557
+ *
4558
+ * The server writes the template's labels in the request's locale, and the
4559
+ * import accepts a translated label only in its own request's locale. The
4560
+ * import request goes through `@objectstack/client`, which stamps
4561
+ * `Accept-Language` with the client's locale when one is set (and the host's
4562
+ * `fetch` stamps its own otherwise). This request bypasses the client, so it
4563
+ * stamps the client's locale the same way. Both requests then carry the same
4564
+ * locale.
4565
+ */
4566
+ async downloadImportTemplate(resource) {
4567
+ const locale = this.client.getLocale();
4568
+ return this.fetchExportBlob(
4569
+ resource,
4570
+ new URLSearchParams({ template: "true" }),
4571
+ locale ? { "Accept-Language": locale } : void 0
4572
+ );
4573
+ }
4574
+ /**
4575
+ * GET `/api/v1/data/:object/export` with the given query and answer the body
4576
+ * as a Blob. A failure throws an `Error` that carries the server's `status`
4577
+ * and its ADR-0112 `code`. The one request path {@link exportDownload} and
4578
+ * {@link downloadImportTemplate} share.
4579
+ */
4580
+ async fetchExportBlob(resource, queryParams, extraHeaders) {
2928
4581
  const baseUrl = this.baseUrl.replace(/\/$/, "");
2929
4582
  const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
2930
4583
  const dataPath = hasApiVersionSuffix ? "/data" : "/api/v1/data";
2931
4584
  const url = `${baseUrl}${dataPath}/${encodeURIComponent(resource)}/export?${queryParams.toString()}`;
2932
- const headers = { ...this.getAuthHeaders() };
4585
+ const headers = { ...this.getAuthHeaders(), ...extraHeaders };
2933
4586
  const res = await this.fetchImpl(url, { method: "GET", headers, credentials: "include" });
2934
4587
  if (!res.ok) {
2935
4588
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
2936
- const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
4589
+ const envelope = readErrorEnvelope(errorBody);
4590
+ const err = new Error(envelope.message ?? res.statusText);
2937
4591
  err.status = res.status;
4592
+ err.code = envelope.code;
2938
4593
  throw err;
2939
4594
  }
2940
4595
  return await res.blob();
@@ -2955,7 +4610,8 @@ var ObjectStackAdapter = class {
2955
4610
  if (Array.isArray(params.$filter)) {
2956
4611
  options.filters = translateFilterArray(params.$filter);
2957
4612
  } else {
2958
- options.filters = convertFiltersToAST(params.$filter);
4613
+ const lowered = convertFiltersToAST(params.$filter);
4614
+ if (lowered !== void 0) options.filters = lowered;
2959
4615
  }
2960
4616
  }
2961
4617
  }
@@ -2990,7 +4646,6 @@ var ObjectStackAdapter = class {
2990
4646
  () => this.fetchObjectSchemaFresh(objectName)
2991
4647
  );
2992
4648
  normalizeSchemaReferenceKeys(schema);
2993
- this.applyFieldWidgetOverrides(objectName, schema);
2994
4649
  return schema;
2995
4650
  } catch (error) {
2996
4651
  const errorObj = error;
@@ -3003,44 +4658,6 @@ var ObjectStackAdapter = class {
3003
4658
  throw createErrorFromResponse(errorObj, `getObjectSchema(${objectName})`);
3004
4659
  }
3005
4660
  }
3006
- /**
3007
- * ADR-0056 P2 (epic #2398) — stamp structured-widget hints onto platform
3008
- * fields whose framework type is a storage primitive (e.g. `textarea`) but
3009
- * whose authoring UX should be a structured editor. Only the render `widget`
3010
- * is added; the field's `type` (the storage contract) is untouched. Applied
3011
- * idempotently to the cached schema so form + detail both honor it. Widget
3012
- * components are registered as `field:<widget>` in `@object-ui/fields`.
3013
- */
3014
- applyFieldWidgetOverrides(objectName, schema) {
3015
- const OVERRIDES = {
3016
- // ADR-0056 pure model — a permission set's six authorization facets are
3017
- // *designed* in Studio's structured editors and only *assigned* (to
3018
- // users) in Setup. In Setup they render read-only as a summary + a
3019
- // "Design in Studio →" deep-link (the `permission-facet-link` widget),
3020
- // never as raw [Object]/JSON. The capability *editor* itself lives in
3021
- // Studio (epic #2398 P2).
3022
- sys_permission_set: {
3023
- object_permissions: "permission-facet-link",
3024
- field_permissions: "permission-facet-link",
3025
- system_permissions: "permission-facet-link",
3026
- row_level_security: "permission-facet-link",
3027
- tab_permissions: "permission-facet-link",
3028
- admin_scope: "permission-facet-link"
3029
- }
3030
- };
3031
- const overrides = OVERRIDES[objectName];
3032
- const fields = schema && typeof schema === "object" ? schema.fields : null;
3033
- if (!overrides || !fields) return;
3034
- for (const [fname, widget] of Object.entries(overrides)) {
3035
- if (Array.isArray(fields)) {
3036
- const f = fields.find((x) => x?.name === fname);
3037
- if (f && !f.widget) f.widget = widget;
3038
- } else {
3039
- const f = fields[fname];
3040
- if (f && !f.widget) f.widget = widget;
3041
- }
3042
- }
3043
- }
3044
4661
  /**
3045
4662
  * Fetch a single object's schema while always revalidating the browser cache.
3046
4663
  *
@@ -3075,13 +4692,16 @@ var ObjectStackAdapter = class {
3075
4692
  });
3076
4693
  if (!res.ok) {
3077
4694
  const errBody = await res.json().catch(() => ({ message: res.statusText }));
3078
- const err = new Error(errBody?.error?.message || errBody?.message || res.statusText);
4695
+ const envelope = readErrorEnvelope(errBody);
4696
+ const err = new Error(envelope.message ?? res.statusText);
3079
4697
  err.status = res.status;
4698
+ err.code = envelope.code;
3080
4699
  throw err;
3081
4700
  }
3082
4701
  const body = await res.json();
3083
4702
  const data = body && typeof body === "object" && "success" in body && "data" in body ? body.data : body;
3084
- return data && typeof data === "object" && "item" in data ? data.item : data;
4703
+ const item = data && typeof data === "object" && "item" in data ? data.item : data;
4704
+ return attachObjectSortability(item, data && typeof data === "object" ? data.sortability : void 0);
3085
4705
  }
3086
4706
  /**
3087
4707
  * List every registered object (code- and DB-defined) from the metadata
@@ -3112,6 +4732,38 @@ var ObjectStackAdapter = class {
3112
4732
  return [];
3113
4733
  }
3114
4734
  }
4735
+ /**
4736
+ * List the items one metadata registry type declares —
4737
+ * `GET /api/v1/meta/:type`, through the client's `meta.getItems` — as
4738
+ * `{ name, label }` headers for the `position-ref` / `permission-ref` field
4739
+ * widgets (objectui#7611).
4740
+ *
4741
+ * Unlike {@link getObjects}, a failure REJECTS (the `DataSource` contract):
4742
+ * the transport's own error propagates, and an answer without the spec's
4743
+ * `items` list is refused rather than read as an empty registry, so a
4744
+ * failed load can never reach the widget as "this registry declares
4745
+ * nothing". Uncached, like `getObjects`: an item authored in Studio is
4746
+ * listed on the next form that opens.
4747
+ *
4748
+ * An item without a string `name` is not listed (the widget stores the
4749
+ * name, so there is nothing to store); a `label` that is not a string is
4750
+ * left off rather than coerced.
4751
+ */
4752
+ async listMetadataItems(type) {
4753
+ await this.connect();
4754
+ const result = await this.client.meta.getItems(type);
4755
+ if (!Array.isArray(result?.items)) {
4756
+ throw new Error(`GET /meta/${type} answered without an items list`);
4757
+ }
4758
+ const out = [];
4759
+ for (const it of result.items) {
4760
+ const name = it?.name;
4761
+ if (typeof name !== "string" || !name) continue;
4762
+ const label = it.label;
4763
+ out.push(typeof label === "string" && label ? { name, label } : { name });
4764
+ }
4765
+ return out;
4766
+ }
3115
4767
  /**
3116
4768
  * Get access to the underlying ObjectStack client for advanced operations.
3117
4769
  */
@@ -3178,14 +4830,16 @@ var ObjectStackAdapter = class {
3178
4830
  invalidateViewKeys(objectName, viewName) {
3179
4831
  this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
3180
4832
  this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
4833
+ this.metadataCache.invalidate?.(VIEW_LIST_KEY);
3181
4834
  }
3182
4835
  /**
3183
4836
  * Batch-fetch all persisted view overrides for an object.
3184
4837
  *
3185
4838
  * Per-view runtime overrides (density, column widths, sort, hidden
3186
- * columns, inlineEdit …) live in the SAME metadata namespace the
3187
- * write path uses: `type='view'`, `name=<viewId>` (see
3188
- * {@link updateViewConfig}). Loading them per-view fires N HTTP GETs
4839
+ * columns, inlineEdit …) live in the SAME metadata namespace the view
4840
+ * writes use: `type='view'`, `name=<viewId>` (see {@link createView} /
4841
+ * {@link updateView}; the toolbar's `updateViewConfig` wrote overlays there
4842
+ * until objectui#12504 retired it). Loading them per-view fires N HTTP GETs
3189
4843
  * that 404 for every view the user never customized — console noise on
3190
4844
  * every page load. This batch method performs a single
3191
4845
  * `GET /api/v1/meta/view` (returns `{type, items}`) and narrows the
@@ -3209,9 +4863,9 @@ var ObjectStackAdapter = class {
3209
4863
  *
3210
4864
  * Result is cached identically to {@link getView}, and EVERY view write —
3211
4865
  * on this adapter and above it — invalidates it through the one seam,
3212
- * {@link invalidateViewKeys}. For a long time only {@link updateViewConfig}
3213
- * did (objectui#4363), which left the other three adapter paths stale
3214
- * for the cache's 5-minute TTL. That gap does not self-heal: the consumer
4866
+ * {@link invalidateViewKeys}. For a long time only `updateViewConfig` (since
4867
+ * retired, objectui#12504) did (objectui#4363), which left the other three
4868
+ * adapter paths stale for the cache's 5-minute TTL. That gap does not self-heal: the consumer
3215
4869
  * (`loadViewOverrides`, app-shell `ObjectView`) treats a RESOLVED map as
3216
4870
  * authoritative and deliberately does not re-probe per view — objectui#3774,
3217
4871
  * and correct, since re-probing reinstates the 404 flurry the batch read
@@ -3227,7 +4881,7 @@ var ObjectStackAdapter = class {
3227
4881
  await this.connect();
3228
4882
  const cacheKey = `view-overrides:${objectName}`;
3229
4883
  return await this.metadataCache.get(cacheKey, async () => {
3230
- const result = await this.client.meta.getItems("view");
4884
+ const result = await this.readViewList();
3231
4885
  const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
3232
4886
  const out = {};
3233
4887
  for (const it of items) {
@@ -3262,69 +4916,6 @@ var ObjectStackAdapter = class {
3262
4916
  return null;
3263
4917
  }
3264
4918
  }
3265
- /**
3266
- * Persist a toolbar-driven view config patch — density, column widths,
3267
- * sort, hidden columns, inline edit. Symmetric counterpart to
3268
- * {@link getView}: writes the row to the server metadata store via
3269
- * `client.meta.saveItem`, then invalidates the matching cache entry so the
3270
- * next {@link getView} reflects the new payload. Returns the persisted item
3271
- * when the server echoes it, otherwise undefined.
3272
- *
3273
- * Called from exactly ONE production site — `ObjectView`'s
3274
- * `persistViewPatch`, for the toolbar toggle — but that ONE call site
3275
- * fires for BOTH kinds of active tab: a code-defined **system** view (no
3276
- * row of its own yet) and a genuinely user-created **saved** view (already
3277
- * has a row — the toggle is editing ITS OWN definition, not laying an
3278
- * overlay on top of it). Which one a given call means is NOT re-derived
3279
- * here from the write's shape (objectui#4227's own lesson: shape inference
3280
- * on this namespace is exactly what let a system view masquerade as
3281
- * saved) — the caller already knows, via the same `isSavedViewId`
3282
- * classification that gates the switcher's readonly flag and its five
3283
- * mutating handlers, and passes it as {@link opts.isSavedView}.
3284
- *
3285
- * - `isSavedView` false/omitted (system-view target, the common case and
3286
- * the default for backward compatibility): stamps
3287
- * {@link VIEW_OVERLAY_MARKER} so {@link listViews} excludes the row —
3288
- * the original objectui#4227 fix.
3289
- * - `isSavedView` true: the marker is withheld. Stamping it here would
3290
- * flag the saved view's OWN row as a personalization overlay, and
3291
- * `listViews()` would exclude it on the very next read — the user's own
3292
- * view would vanish from the switcher the moment they toggled its
3293
- * density (objectui#4227 follow-up, PM review on PR #4713, measured:
3294
- * `persistViewPatch` has no gate on which kind of tab is active, and
3295
- * this method writes to the exact same `(type='view', name=viewId)` key
3296
- * {@link createView}/the ADR-0034 `viewEnvelope` seam already used for
3297
- * that view, so the write is an upsert onto the saved view's row, not a
3298
- * new one).
3299
- *
3300
- * Per objectstack#7494's ruling, the overlay this writes (system-view
3301
- * case) is ORG-WIDE shared view settings, not a per-user preference — a
3302
- * true per-user scope is a parked v18 direction on the platform side.
3303
- *
3304
- * @param objectName - Object name (e.g. 'lead')
3305
- * @param viewId - View identifier (e.g. 'all_leads')
3306
- * @param config - Full view definition to persist
3307
- * @param opts.isSavedView - Whether `viewId` already names a saved view
3308
- * (vs. a system view being personalized for the first time). Omit /
3309
- * `false` for the default overlay-marking behavior.
3310
- */
3311
- async updateViewConfig(objectName, viewId, config, opts) {
3312
- await this.connect();
3313
- const merged = {
3314
- ...config || {},
3315
- object: config?.object || objectName,
3316
- name: viewId,
3317
- ...opts?.isSavedView ? {} : { [VIEW_OVERLAY_MARKER]: true }
3318
- };
3319
- const result = await this.client.meta.saveItem(
3320
- "view",
3321
- viewId,
3322
- merged
3323
- );
3324
- this.invalidateViewKeys(objectName, viewId);
3325
- if (result && result.item) return result.item;
3326
- return result ?? void 0;
3327
- }
3328
4919
  /**
3329
4920
  * List user-created views for a given object via the metadata overlay
3330
4921
  * API (ADR-0005). Replaces the legacy `find('sys_view', {...})` path
@@ -3336,6 +4927,42 @@ var ObjectStackAdapter = class {
3336
4927
  * the metadata index is name-only, not field-typed, so the route has no
3337
4928
  * `?object=` to push the filter down into. {@link listViewOverrides}
3338
4929
  * reads the same rows through the same accessor.
4930
+ *
4931
+ * ## Every failure still degrades to an empty list — and now says which kind
4932
+ * ## of failure it was (objectui#8151)
4933
+ *
4934
+ * The degrade is unchanged and deliberate, exactly as it is one method over
4935
+ * in {@link listImportMappings}: a host with no metadata door keeps answering
4936
+ * `[]` rather than breaking the page. What changed is that the OTHER failures
4937
+ * no longer render as that one.
4938
+ *
4939
+ * This method's empty list is read as *this object has no saved views*, and
4940
+ * that reading reaches the user in two places measured on this tree: the list
4941
+ * view switcher simply shows fewer tabs, and `@object-ui/core`'s
4942
+ * `elementDataSourceViewNotFoundMessage` states it outright. So a session
4943
+ * whose token lapsed mid-read was shown an object that appears to have no
4944
+ * saved views AT ALL — including ones the user created — with a
4945
+ * `console.warn` as the only discriminator, in a console nothing in the UI
4946
+ * points at. That is the silence objectui#7741 removed from the sibling after
4947
+ * it produced a confident wrong diagnosis in objectstack#14026.
4948
+ *
4949
+ * The `catch` now asks {@link classifyViewsFailure} what the failure WAS,
4950
+ * reading the error's own ADR-0112 `code` and status — never "is the result
4951
+ * empty", which is what both conditions produce and so can never tell them
4952
+ * apart — and anything that is not the doorless shape is announced on
4953
+ * {@link onMetadataReadWarning}.
4954
+ *
4955
+ * ⛔ That classifier is a SEPARATE reading, not a second caller of the
4956
+ * sibling's: `view`'s quiet set is strictly smaller, because the arm
4957
+ * `listImportMappings` is built around (*an older server without this kind*)
4958
+ * has no members here. See {@link classifyViewsFailure} for the measurement.
4959
+ *
4960
+ * ⛔ The RETURN is untouched. This method has answered `Promise<any[]>`, never
4961
+ * throwing, since `@object-ui/data-objectstack@17.1.0`; a consumer that reads
4962
+ * nothing new sees exactly what it saw before, including on the loud arms.
4963
+ * Applying framework objectstack#13906 decision 1 option A — *a thing that could not be
4964
+ * READ is not a thing that is ABSENT* — is done by ADDING a channel, not by
4965
+ * moving that contract.
3339
4966
  */
3340
4967
  async listViews(objectName, options) {
3341
4968
  await this.connect();
@@ -3345,7 +4972,7 @@ var ObjectStackAdapter = class {
3345
4972
  const draftItems = await this.metadataClient().withPreviewDrafts(true).list("view");
3346
4973
  items = Array.isArray(draftItems) ? draftItems : [];
3347
4974
  } else {
3348
- const result = await this.client.meta.getItems("view");
4975
+ const result = await this.readViewList();
3349
4976
  items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
3350
4977
  }
3351
4978
  const FORM_FAMILY = /* @__PURE__ */ new Set(["form", "detail"]);
@@ -3361,18 +4988,35 @@ var ObjectStackAdapter = class {
3361
4988
  const spec = v.list ?? v;
3362
4989
  const isDraft = v._draft === true || spec?._draft === true;
3363
4990
  if (spec && spec.config && typeof spec.config === "object") {
3364
- return {
4991
+ const row = {
3365
4992
  ...spec.config,
3366
4993
  name: spec.name ?? spec.config.name,
3367
- label: spec.label ?? spec.config.label,
3368
- isDefault: !!spec.isDefault,
3369
- ...isDraft ? { _draft: true } : {}
4994
+ label: spec.label ?? spec.config.label
3370
4995
  };
4996
+ if (spec.object !== void 0) row.object = spec.object;
4997
+ for (const key of VIEW_ITEM_ROUND_TRIP_KEYS) {
4998
+ if (spec[key] !== void 0) row[key] = spec[key];
4999
+ }
5000
+ row.isDefault = !!spec.isDefault;
5001
+ if (isDraft) row._draft = true;
5002
+ return row;
3371
5003
  }
3372
5004
  return isDraft ? { ...spec, _draft: true } : spec;
3373
5005
  });
3374
5006
  } catch (err) {
3375
5007
  console.warn("[OBJECTSTACKDataSource] listViews failed:", err);
5008
+ const failure = classifyViewsFailure(err);
5009
+ if (failure.kind !== "not-served") {
5010
+ this.emitMetadataReadWarning({
5011
+ operation: "listViews",
5012
+ kind: "view",
5013
+ objectName,
5014
+ reason: failure.kind,
5015
+ ...failure.code !== void 0 ? { code: failure.code } : {},
5016
+ ...failure.status !== void 0 ? { status: failure.status } : {},
5017
+ ...failure.message !== void 0 ? { message: failure.message } : {}
5018
+ });
5019
+ }
3376
5020
  return [];
3377
5021
  }
3378
5022
  }
@@ -3391,11 +5035,36 @@ var ObjectStackAdapter = class {
3391
5035
  }
3392
5036
  /**
3393
5037
  * List registered import `mapping` artifacts targeting a given object
3394
- * (framework #2611). Reads the `mapping` metadata kind via the overlay API
5038
+ * (objectstack-ai/objectstack#2611). Reads the `mapping` metadata kind via the overlay API
3395
5039
  * and filters by `targetObject` client-side (the metadata index is
3396
- * name-only). Feeds the import wizard's "saved mapping" selector; a failure
3397
- * (older server without the `mapping` kind) degrades to an empty list, so
3398
- * the selector simply doesn't appear.
5040
+ * name-only). Feeds the import wizard's "saved mapping" selector.
5041
+ *
5042
+ * ## Every failure still degrades to an empty list — and now says which kind
5043
+ * ## of failure it was (objectui#7741)
5044
+ *
5045
+ * The degrade is unchanged and deliberate: an older server without the
5046
+ * `mapping` kind must not break the wizard, so it keeps answering `[]` and
5047
+ * the selector simply doesn't appear. That is a real, supported deployment
5048
+ * shape and it stays quiet.
5049
+ *
5050
+ * What changed is that the OTHER failures no longer render as that one. A
5051
+ * refused door and a broken one used to be indistinguishable, in the UI, from
5052
+ * "no mapping is registered" — both were an empty list behind a
5053
+ * `console.warn` nothing pointed at — and that silence produced a confident
5054
+ * wrong diagnosis in a careful reporter (objectstack#14026 was filed, routed
5055
+ * and worked by two seats against a wizard that had been correct since
5056
+ * 17.1.0). So the `catch` now asks {@link classifyImportMappingsFailure} what
5057
+ * the failure WAS, reading the error's own ADR-0112 `code` and status —
5058
+ * never "is the result empty", which is what both conditions produce and so
5059
+ * can never tell them apart — and anything that is not the supported
5060
+ * kind-absent shape is announced on {@link onMetadataReadWarning}.
5061
+ *
5062
+ * ⛔ The RETURN is untouched. This method has answered `Promise<any[]>`, never
5063
+ * throwing, since `@object-ui/data-objectstack@17.1.0`; a consumer that reads
5064
+ * nothing new sees exactly what it saw before, including on the loud arms.
5065
+ * Applying framework objectstack#13906 decision 1 option A — *a thing that could not be
5066
+ * READ is not a thing that is ABSENT* — is done by ADDING a channel, not by
5067
+ * moving that contract.
3399
5068
  */
3400
5069
  async listImportMappings(objectName) {
3401
5070
  await this.connect();
@@ -3405,6 +5074,18 @@ var ObjectStackAdapter = class {
3405
5074
  return items.filter((m) => m && m.targetObject === objectName);
3406
5075
  } catch (err) {
3407
5076
  console.warn("[OBJECTSTACKDataSource] listImportMappings failed:", err);
5077
+ const failure = classifyImportMappingsFailure(err);
5078
+ if (failure.kind !== "not-served") {
5079
+ this.emitMetadataReadWarning({
5080
+ operation: "listImportMappings",
5081
+ kind: "mapping",
5082
+ objectName,
5083
+ reason: failure.kind,
5084
+ ...failure.code !== void 0 ? { code: failure.code } : {},
5085
+ ...failure.status !== void 0 ? { status: failure.status } : {},
5086
+ ...failure.message !== void 0 ? { message: failure.message } : {}
5087
+ });
5088
+ }
3408
5089
  return [];
3409
5090
  }
3410
5091
  }
@@ -3580,6 +5261,10 @@ var ObjectStackAdapter = class {
3580
5261
  * the PUBLISHED half after the draft was discarded carries the partial
3581
5262
  * state on the error's `outcome` — "draft gone, overlay left" is exactly
3582
5263
  * what the old `{ deleted: boolean }` could not express.
5264
+ * Its message is the adapter's "NOT fully removed" sentence followed, on
5265
+ * the next line, by the server's own reason (objectui#12416), so
5266
+ * `formatMetadataError(err)` shows the remedy a refusal names; the
5267
+ * original error stays on `cause`.
3583
5268
  *
3584
5269
  * Invalidates through {@link invalidateViewKeys}: the deleted row leaves the
3585
5270
  * batch override map too, and a ghost entry there is what the object page
@@ -3605,9 +5290,12 @@ var ObjectStackAdapter = class {
3605
5290
  draft,
3606
5291
  published: { removed: false }
3607
5292
  };
5293
+ const status = err?.status;
5294
+ const refused = typeof status === "number" && status >= 400 && status < 500;
3608
5295
  throw Object.assign(
3609
5296
  new Error(
3610
- `deleteView: view "${viewName}" on object "${objectName}" is NOT fully removed \u2014 the draft home was ${draft.removed ? "discarded" : "already absent"}, but deleting the published overlay failed. The published row is still serving the view; retry the delete.`
5297
+ `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."}
5298
+ ` + formatMetadataError(err)
3611
5299
  ),
3612
5300
  { cause: err, outcome }
3613
5301
  );
@@ -3659,11 +5347,17 @@ var ObjectStackAdapter = class {
3659
5347
  *
3660
5348
  * The maintainer ruling (2026-08-12) put the answer on the BY-NAME route
3661
5349
  * rather than in the list, so the enumeration surface is not widened past what
3662
- * a by-name probe already implies (objectstack#8013 / PR #8135): an app that
5350
+ * a by-name probe already implies (objectstack#8013 / PR objectstack-ai/objectstack#8135): an app that
3663
5351
  * exists and whose `requiredPermissions` the session lacks answers `403` with
3664
5352
  * `PERMISSION_DENIED` in the declared envelope, and absence — a nonexistent
3665
- * name, an unpublished app, an app gated by an absent optional service —
3666
- * keeps answering `404 RESOURCE_NOT_FOUND`.
5353
+ * name, an unpublished app, an app gated by an absent optional service — is
5354
+ * one undifferentiated answer.
5355
+ *
5356
+ * ⚠️ That absence is NOT uniformly a `404`, which this docstring asserted
5357
+ * until objectui#9262 measured it: the 404 covers an app that EXISTS and is
5358
+ * withheld for a non-permission reason, while a name that resolves to nothing
5359
+ * answers `200` with an envelope carrying no `item`. Both are absence and both
5360
+ * read as `not_found`; see {@link metaEnvelopeCarriesItem} for the measurement.
3667
5361
  *
3668
5362
  * ## Why this is a separate method and not a flavour of {@link getApp}
3669
5363
  *
@@ -3674,19 +5368,68 @@ var ObjectStackAdapter = class {
3674
5368
  * be cached beside a document about the APP — one grant, and a cached
3675
5369
  * denial outlives the session it described.
3676
5370
  *
3677
- * Nothing here throws: a probe that cannot reach an answer returns `unknown`
3678
- * and the caller keeps whatever it was already showing. Only the measured
3679
- * `code` produces `denied` — never a status, never a message (objectui#4408).
5371
+ * Nothing here throws: every outcome is a verdict the caller renders a screen
5372
+ * from, never a catch block. Only the measured `code` produces `denied` —
5373
+ * never a status, never a message (objectui#4408).
5374
+ *
5375
+ * ## objectui#9262 — what this method stopped folding away
5376
+ *
5377
+ * It used to answer `granted` whenever the call did not throw, and `unknown`
5378
+ * for everything that did. Both halves were wrong in the same direction, and
5379
+ * the first was measured on a real server before this change landed:
5380
+ *
5381
+ * - **an app that does not exist answers `200` with the envelope minus its
5382
+ * `item`**, so "did not throw" reported `granted` for a name with nothing
5383
+ * behind it — the shape issue objectui#9262 listed as cause 8 and expected
5384
+ * to be structural. It is the ordinary case: every typo, every never-created
5385
+ * app and every unpublished draft took it. See
5386
+ * {@link metaEnvelopeCarriesItem} for the measurement and the server-side
5387
+ * mechanism.
5388
+ * - a 404 absence and an unreachable server were one `unknown`, though the
5389
+ * transport had already told them apart.
5390
+ *
5391
+ * The verdicts now say only what was obtained: a served document, a refusal
5392
+ * naming permission, an answer that there is no such app here, or no answer
5393
+ * at all. `unknown` is left for the one case where nothing was asked.
3680
5394
  *
3681
5395
  * @param appName - the app name as it appears in the URL segment
3682
5396
  */
3683
5397
  async probeAppAccess(appName) {
3684
- if (!appName) return "unknown";
5398
+ return (await this.probeMetaItemAccess("app", appName)).verdict;
5399
+ }
5400
+ /**
5401
+ * {@link probeAppAccess} for any metadata type whose by-name read refuses a
5402
+ * caller (objectui#12112): why `name` is not in this session's list of
5403
+ * `type`, plus the server's own sentence when it refused.
5404
+ *
5405
+ * Since objectstack#22731 a dashboard and a list view carry the app's gate:
5406
+ * `GET /api/v1/meta/dashboard` and `/meta/view` list only what the caller may
5407
+ * open, so a withheld item and a missing one are the same absence there, and
5408
+ * the by-name read of one the caller does not hold answers `403`
5409
+ * `PERMISSION_DENIED`, "the app arm's whole refusal (same emitter, same
5410
+ * envelope)". The verdicts are therefore read exactly as for an app.
5411
+ *
5412
+ * `message` is the refusal's `message`, as the server wrote it: the one
5413
+ * sentence that names the item and says it was refused. Only on `denied`.
5414
+ *
5415
+ * Nothing here throws, and nothing is cached: a verdict about the CALLER
5416
+ * must not outlive a grant.
5417
+ *
5418
+ * @param type - the singular metadata type (`app`, `dashboard`, `view`)
5419
+ * @param name - the item's name, as the by-name route addresses it
5420
+ */
5421
+ async probeMetaItemAccess(type, name) {
5422
+ if (!type || !name) return { verdict: "unknown" };
3685
5423
  try {
3686
- await this.client.meta.getItem("app", appName);
3687
- return "granted";
5424
+ const envelope = await this.client.meta.getItem(type, name);
5425
+ return { verdict: metaEnvelopeCarriesItem(envelope) ? "granted" : "not_found" };
3688
5426
  } catch (err) {
3689
- return isAppPermissionDeniedError(err) ? "denied" : "unknown";
5427
+ if (isAppPermissionDeniedError(err)) {
5428
+ const message = err?.message;
5429
+ return typeof message === "string" && message.trim() ? { verdict: "denied", message } : { verdict: "denied" };
5430
+ }
5431
+ if (isMetaItemAbsentError(err)) return { verdict: "not_found" };
5432
+ return { verdict: "unreachable" };
3690
5433
  }
3691
5434
  }
3692
5435
  /**
@@ -3736,7 +5479,8 @@ var ObjectStackAdapter = class {
3736
5479
  * Uses `this.client.analytics.query()` from @objectstack/client to leverage
3737
5480
  * the SDK's built-in auth, headers, and fetch configuration.
3738
5481
  * Falls back to client-side aggregation via find() if the analytics endpoint
3739
- * is not available.
5482
+ * is not available. Throws {@link AnalyticsResultShapeError} when it answers
5483
+ * with anything but an `AnalyticsResult` carrying `rows`.
3740
5484
  */
3741
5485
  async aggregate(resource, params) {
3742
5486
  await this.connect();
@@ -3745,7 +5489,11 @@ var ObjectStackAdapter = class {
3745
5489
  const queryAst = {};
3746
5490
  if (Array.isArray(params.groupBy)) queryAst.groupBy = params.groupBy;
3747
5491
  if (Array.isArray(params.aggregations)) queryAst.aggregations = params.aggregations;
3748
- if (params.where !== void 0) queryAst.where = params.where;
5492
+ if (params.where !== void 0) {
5493
+ assertSpecShapeWhereIsFilterAst(params.where, resource);
5494
+ queryAst.where = params.where;
5495
+ }
5496
+ assertNoAnalyticsKeysOnSpecShape(params, resource);
3749
5497
  if (typeof params.limit === "number") queryAst.limit = params.limit;
3750
5498
  const result = await this.client.data.query(resource, queryAst);
3751
5499
  if (Array.isArray(result)) return result;
@@ -3753,6 +5501,7 @@ var ObjectStackAdapter = class {
3753
5501
  if (Array.isArray(result?.data)) return result.data;
3754
5502
  return [];
3755
5503
  }
5504
+ const analyticsWhere = params.filter ? lowerAnalyticsFilterForWire(params.filter, resource) : void 0;
3756
5505
  try {
3757
5506
  const measureName = params.function === "count" ? "count" : `${params.field}_${params.function}`;
3758
5507
  const valueKey = this.aggregateValueKey(params);
@@ -3762,17 +5511,15 @@ var ObjectStackAdapter = class {
3762
5511
  // When groupBy is '_all' no dimensions are needed (single-bucket).
3763
5512
  dimensions: params.groupBy && params.groupBy !== "_all" ? [params.groupBy] : []
3764
5513
  };
3765
- if (params.filter) {
3766
- payload.where = params.filter;
5514
+ if (analyticsWhere !== void 0) payload.where = analyticsWhere;
5515
+ const contractResult = await this.client.analytics.query(payload);
5516
+ if (!Array.isArray(contractResult?.rows)) {
5517
+ throw new AnalyticsResultShapeError(resource, contractResult);
3767
5518
  }
3768
- const data = await this.client.analytics.query(payload);
3769
- 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 : [];
3770
- const measureMissing = rawRows.length > 0 && rawRows.every((row) => {
3771
- if (row == null) return true;
3772
- if (measureName in row && row[measureName] != null) return false;
3773
- if (valueKey in row && row[valueKey] != null) return false;
3774
- return true;
3775
- });
5519
+ const rawRows = contractResult.rows;
5520
+ const measureMissing = rawRows.length > 0 && rawRows.every(
5521
+ (row) => row == null || !(measureName in row) && !(valueKey in row)
5522
+ );
3776
5523
  if (measureMissing) {
3777
5524
  return await this.aggregateViaFind(resource, params);
3778
5525
  }
@@ -3785,6 +5532,7 @@ var ObjectStackAdapter = class {
3785
5532
  return mapped;
3786
5533
  });
3787
5534
  } catch (e) {
5535
+ if (e instanceof AnalyticsResultShapeError) throw e;
3788
5536
  const failure = classifyAnalyticsFailure(e);
3789
5537
  if (failure.kind === "rejected") {
3790
5538
  throw new AnalyticsQueryRejectedError(failure.message, failure.code);
@@ -3804,6 +5552,34 @@ var ObjectStackAdapter = class {
3804
5552
  return await this.aggregateViaFind(resource, params);
3805
5553
  }
3806
5554
  }
5555
+ /**
5556
+ * Answer a grouped list view's GROUP HEADER query (objectui#7189) — see
5557
+ * `DataSource.queryGroupHeaders` in `@object-ui/types` for the contract.
5558
+ *
5559
+ * The compiled query rides the platform's EXISTING data door verbatim:
5560
+ * `client.data.query()` posts it to `POST /data/:object/query`, whose
5561
+ * `findData` routes a body carrying `groupBy` / `aggregations` to
5562
+ * `engine.aggregate` and answers the header rows as `records` (the door the
5563
+ * spec half of objectstack#14556 names, pinned platform-side by
5564
+ * objectstack#15330). No new route, no new wire shape.
5565
+ *
5566
+ * ⛔ No fallback, deliberately — unlike {@link aggregate}. A header query that
5567
+ * degraded to bucketing a fetched page would answer page slices under a name
5568
+ * that promises the query's own numbers, which is the defect this member
5569
+ * exists to end. A failure is thrown to the caller, and a body without a
5570
+ * `records` array is refused rather than read as "no groups".
5571
+ */
5572
+ async queryGroupHeaders(resource, query) {
5573
+ await this.connect();
5574
+ const result = await this.client.data.query(resource, query);
5575
+ const records = result?.records;
5576
+ if (!Array.isArray(records)) {
5577
+ throw new Error(
5578
+ `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.`
5579
+ );
5580
+ }
5581
+ return records;
5582
+ }
3807
5583
  /**
3808
5584
  * Client-side aggregation over a server-scoped `find()` — the fallback used
3809
5585
  * whenever the analytics endpoint cannot answer (capability absent, network
@@ -3899,6 +5675,13 @@ var ObjectStackAdapter = class {
3899
5675
  if (errorCodeIs({ code: serverCode }, "UNAUTHENTICATED")) {
3900
5676
  throw new AnalyticsUnauthenticatedError({ serverCode, serverMessage });
3901
5677
  }
5678
+ if (errorCodeIs({ code: serverCode }, "PERMISSION_DENIED")) {
5679
+ throw new AnalyticsForbiddenError({
5680
+ serverCode,
5681
+ serverMessage,
5682
+ datasetName: requestedDatasetName
5683
+ });
5684
+ }
3902
5685
  if (serverCode === void 0) {
3903
5686
  if (ANALYTICS_ABSENT_STATUSES.includes(res.status)) {
3904
5687
  throw new AnalyticsNotInstalledError(surface, detail || void 0);
@@ -4039,9 +5822,10 @@ var ObjectStackAdapter = class {
4039
5822
  }
4040
5823
  });
4041
5824
  if (!response.ok) {
4042
- const error = await response.json().catch(() => ({ message: response.statusText }));
5825
+ const errorBody = await response.json().catch(() => ({ message: response.statusText }));
5826
+ const envelope = readErrorEnvelope(errorBody);
4043
5827
  throw new ObjectStackError(
4044
- error.message || `Upload failed with status ${response.status}`,
5828
+ envelope.message ?? `Upload failed with status ${response.status}`,
4045
5829
  "UPLOAD_ERROR",
4046
5830
  response.status
4047
5831
  );
@@ -4081,9 +5865,10 @@ var ObjectStackAdapter = class {
4081
5865
  }
4082
5866
  });
4083
5867
  if (!response.ok) {
4084
- const error = await response.json().catch(() => ({ message: response.statusText }));
5868
+ const errorBody = await response.json().catch(() => ({ message: response.statusText }));
5869
+ const envelope = readErrorEnvelope(errorBody);
4085
5870
  throw new ObjectStackError(
4086
- error.message || `Upload failed with status ${response.status}`,
5871
+ envelope.message ?? `Upload failed with status ${response.status}`,
4087
5872
  "UPLOAD_ERROR",
4088
5873
  response.status
4089
5874
  );
@@ -4166,29 +5951,50 @@ function createObjectStackAdapter(config) {
4166
5951
  API_ACCESS_DENIED_CODES,
4167
5952
  APP_PERMISSION_DENIED_CODE,
4168
5953
  AnalyticsDatasetNotFoundError,
5954
+ AnalyticsForbiddenError,
5955
+ AnalyticsKeysOnSpecShapeError,
4169
5956
  AnalyticsNotInstalledError,
4170
5957
  AnalyticsQueryRejectedError,
5958
+ AnalyticsResultShapeError,
4171
5959
  AnalyticsUnauthenticatedError,
4172
5960
  AuthenticationError,
4173
5961
  BulkOperationError,
5962
+ CHOICE_TYPES_REQUIRING_OPTIONS,
4174
5963
  ConcurrentUpdateError,
4175
5964
  ConnectionError,
4176
5965
  DataApiValidationError,
4177
5966
  FILTER_OPERATOR_ALIASES,
5967
+ META_ITEM_ABSENT_CODE,
4178
5968
  MalformedFilterError,
4179
5969
  MetadataClient,
4180
5970
  MetadataNotFoundError,
5971
+ OBJECT_METADATA_TYPE,
4181
5972
  ObjectStackAdapter,
4182
5973
  ObjectStackError,
5974
+ PREFERENCE_FAMILY_ROW_LIMIT,
5975
+ RELATIONSHIP_TYPES_REQUIRING_REFERENCE,
5976
+ UNRECOGNIZED_DROP_REASON,
5977
+ UnlowerableAnalyticsFilterError,
5978
+ UnloweredAggregateWhereError,
4183
5979
  VIEW_OVERLAY_OWNED_KEYS,
5980
+ assertObjectMetadataWritable,
4184
5981
  classifyAnalyticsFailure,
5982
+ classifyImportMappingsFailure,
5983
+ classifyViewsFailure,
4185
5984
  clearSharedDiscoveryCache,
4186
5985
  createErrorFromResponse,
4187
5986
  createObjectStackAdapter,
5987
+ createObjectStackUserPreferenceFamily,
4188
5988
  createObjectStackUserStateAdapter,
4189
5989
  createQuietHttpLogger,
5990
+ draftChangesUnder,
5991
+ dropServedPicklistOptions,
5992
+ extractDraftBody,
4190
5993
  formatHttpFailureMessage,
5994
+ formatMetadataError,
5995
+ formatMetadataIssue,
4191
5996
  getSharedDiscovery,
5997
+ governingDraftChange,
4192
5998
  is404Error,
4193
5999
  isAnalyticsNotInstalledError,
4194
6000
  isApiAccessDeniedError,
@@ -4196,9 +6002,11 @@ function createObjectStackAdapter(config) {
4196
6002
  isConcurrentUpdateError,
4197
6003
  isErrorType,
4198
6004
  isMalformedFilterError,
6005
+ isMetaItemAbsentError,
4199
6006
  isObjectStackError,
4200
6007
  narrowPersonalizationOverlay,
4201
6008
  normaliseClientError,
6009
+ readDraftChanges,
4202
6010
  readSaveAdvisories,
4203
6011
  readTransactionalBatchCapability,
4204
6012
  serializeOrderBy,