@object-ui/data-objectstack 17.5.0 → 17.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +2311 -0
  2. package/README.md +557 -11
  3. package/dist/index.cjs +1663 -463
  4. package/dist/index.d.cts +1780 -518
  5. package/dist/index.d.ts +1780 -518
  6. package/dist/index.js +1648 -457
  7. package/package.json +9 -9
  8. package/dist/tsconfig.tsbuildinfo +0 -1
  9. package/src/aggregate-capability.test.ts +0 -177
  10. package/src/apiAccessDenied.test.ts +0 -233
  11. package/src/appAccessProbe.test.ts +0 -147
  12. package/src/bulkDelete.test.ts +0 -75
  13. package/src/bulkUpdate.test.ts +0 -93
  14. package/src/cache/MetadataCache.test.ts +0 -477
  15. package/src/cache/MetadataCache.ts +0 -289
  16. package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
  17. package/src/connection.test.ts +0 -188
  18. package/src/contracts.ts +0 -115
  19. package/src/deleteView.homes.test.ts +0 -389
  20. package/src/deleteViewContract.types.test.ts +0 -246
  21. package/src/errors.test.ts +0 -426
  22. package/src/errors.ts +0 -293
  23. package/src/exportDownload.test.ts +0 -141
  24. package/src/filter-entry-translation.test.ts +0 -360
  25. package/src/filter-operator-ast-parity.test.ts +0 -135
  26. package/src/getObjectSchema.test.ts +0 -145
  27. package/src/httpFailureLogging.test.ts +0 -109
  28. package/src/importJob.test.ts +0 -71
  29. package/src/index.ts +0 -4442
  30. package/src/integration.ts +0 -200
  31. package/src/listViewOverrides.test.ts +0 -241
  32. package/src/listViews.test.ts +0 -175
  33. package/src/metadata-client.get-envelope.test.ts +0 -171
  34. package/src/metadata-client.saveAdvisories.test.ts +0 -226
  35. package/src/metadata-client.test.ts +0 -303
  36. package/src/metadata-client.ts +0 -1046
  37. package/src/occ.test.ts +0 -101
  38. package/src/onMutation.test.ts +0 -589
  39. package/src/onSaveAdvisory.test.ts +0 -311
  40. package/src/onWriteWarning.test.ts +0 -273
  41. package/src/orderby-serialization.test.ts +0 -115
  42. package/src/queryDataset.test.ts +0 -543
  43. package/src/searchAll.test.ts +0 -120
  44. package/src/security.ts +0 -243
  45. package/src/spec-symbol-batch6.test.ts +0 -326
  46. package/src/studio.ts +0 -152
  47. package/src/updateView.draft.test.ts +0 -242
  48. package/src/userState.test.ts +0 -315
  49. package/src/userState.ts +0 -238
  50. package/src/v3-compat.test.ts +0 -229
  51. package/src/validation-error.test.ts +0 -94
  52. package/src/viewCacheInvalidation.pin.test.ts +0 -260
  53. package/src/viewInvalidationSeam.pin.test.ts +0 -162
package/dist/index.js CHANGED
@@ -4,6 +4,71 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4
4
 
5
5
  // src/index.ts
6
6
  import { ObjectStackClient } from "@objectstack/client";
7
+ import { VIEW_CONSOLE_ROUND_TRIP_KEYS } from "@objectstack/spec/ui";
8
+ import { DroppedFieldsEventSchema } from "@objectstack/spec/data";
9
+ import { isFilterAST, parseFilterAST } from "@objectstack/spec/data";
10
+ import { isRefusedTextComparand as isRefusedTextComparand2, textComparandRefusalReason as textComparandRefusalReason2 } from "@objectstack/spec/data";
11
+ import { stripReadDecorations as stripReadDecorations2 } from "@objectstack/spec/kernel";
12
+
13
+ // src/metadata-client.ts
14
+ import { GetMetaItemLayeredResponseSchema } from "@objectstack/spec/api";
15
+
16
+ // src/object-metadata-write-guard.ts
17
+ var RELATIONSHIP_TYPES_REQUIRING_REFERENCE = ["lookup", "master_detail"];
18
+ var CHOICE_TYPES_REQUIRING_OPTIONS = ["select", "radio"];
19
+ var OBJECT_METADATA_TYPE = "object";
20
+ function isUsableTarget(reference) {
21
+ return typeof reference === "string" && reference.trim() !== "";
22
+ }
23
+ function describeTarget(reference) {
24
+ if (reference === void 0) return "no `reference` key at all";
25
+ if (reference === null) return "a `null` `reference`";
26
+ if (typeof reference !== "string") return `a \`reference\` of type \`${typeof reference}\``;
27
+ if (reference === "") return "an empty `reference`";
28
+ return "a whitespace-only `reference`";
29
+ }
30
+ function hasOptionSource(def) {
31
+ return Array.isArray(def.options) && def.options.length > 0 || typeof def.picklist === "string";
32
+ }
33
+ function describeOptions(options) {
34
+ if (options === void 0) return "no `options` key and no `picklist`";
35
+ if (Array.isArray(options)) return "an empty `options` list and no `picklist`";
36
+ if (options === null) return "a `null` `options` and no `picklist`";
37
+ return `an \`options\` value of type \`${typeof options}\` and no \`picklist\``;
38
+ }
39
+ function fieldEntries(fields) {
40
+ if (Array.isArray(fields)) {
41
+ return fields.flatMap((raw, index) => {
42
+ if (!raw || typeof raw !== "object") return [];
43
+ const record = raw;
44
+ const name = typeof record.name === "string" && record.name ? record.name : `[${index}]`;
45
+ return [{ name, def: record }];
46
+ });
47
+ }
48
+ if (fields && typeof fields === "object") {
49
+ return Object.entries(fields).flatMap(
50
+ ([name, def]) => def && typeof def === "object" ? [{ name, def }] : []
51
+ );
52
+ }
53
+ return [];
54
+ }
55
+ function assertObjectMetadataWritable(type, item, writer) {
56
+ if (String(type) !== OBJECT_METADATA_TYPE) return;
57
+ if (!item || typeof item !== "object") return;
58
+ const fields = item.fields;
59
+ for (const { name, def } of fieldEntries(fields)) {
60
+ if (CHOICE_TYPES_REQUIRING_OPTIONS.includes(String(def.type)) && !hasOptionSource(def)) {
61
+ throw new Error(
62
+ `${writer} refused this object metadata write: 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\` (objectstack#20827), so this draft stays here until it has one. Add an option, or change the field to a non-choice type.`
63
+ );
64
+ }
65
+ if (!RELATIONSHIP_TYPES_REQUIRING_REFERENCE.includes(String(def.type))) continue;
66
+ if (isUsableTarget(def.reference)) continue;
67
+ throw new Error(
68
+ `${writer} refused this object metadata write: the field \`${name}\` is a \`${String(def.type)}\` and carries ${describeTarget(def.reference)}, so it names no object to link to. \`@objectstack/spec\` refuses the same document at the server 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 (objectui#7714). Pick the target object, or change the field to a non-relationship type.`
69
+ );
70
+ }
71
+ }
7
72
 
8
73
  // src/metadata-client.ts
9
74
  function readSaveAdvisories(body) {
@@ -16,6 +81,25 @@ function readSaveAdvisories(body) {
16
81
  return typeof c.rule === "string" && typeof c.path === "string" && typeof c.where === "string" && typeof c.message === "string" && typeof c.hint === "string" && typeof c.severity === "string";
17
82
  });
18
83
  }
84
+ var PROTECTION_ENVELOPE_KEYS = [
85
+ "overlayScope",
86
+ "lock",
87
+ "lockReason",
88
+ "lockSource",
89
+ "lockDocsUrl",
90
+ "provenance",
91
+ "packageId",
92
+ "packageVersion",
93
+ "editable",
94
+ "deletable",
95
+ "resettable"
96
+ ];
97
+ function unrecognizedEnvelopeKeys(body) {
98
+ const shape = GetMetaItemLayeredResponseSchema.shape;
99
+ return PROTECTION_ENVELOPE_KEYS.filter(
100
+ (key) => body[key] !== void 0 && !shape[key].safeParse(body[key]).success
101
+ );
102
+ }
19
103
  var META_PREFIX = "/meta";
20
104
  var API_PREFIX = "/api/v1";
21
105
  function buildBase(config) {
@@ -28,6 +112,16 @@ function buildBase(config) {
28
112
  }
29
113
  return `${trimmed}${scoped}`;
30
114
  }
115
+ function packagesBaseOf(metaBase) {
116
+ const origin = metaBase.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, "");
117
+ return `${origin}${API_PREFIX}/packages`;
118
+ }
119
+ function unwrapDispatcherEnvelope(body) {
120
+ if (!body || typeof body !== "object") return {};
121
+ const root = body;
122
+ const data = root.data;
123
+ return data && typeof data === "object" && !Array.isArray(data) ? data : root;
124
+ }
31
125
  async function parseError(res) {
32
126
  let body;
33
127
  try {
@@ -60,7 +154,7 @@ var MetadataClient = class _MetadataClient {
60
154
  __publicField(this, "headers");
61
155
  /** ADR-0037: when true, reads render the draft-overlaid world. */
62
156
  __publicField(this, "previewDrafts");
63
- /** #4133 — sink for post-save advisory findings; see the config field. */
157
+ /** #4133 / #5026 — sink for every write door's advisory findings; see the config field. */
64
158
  __publicField(this, "onSaveAdvisory");
65
159
  this.base = buildBase(config);
66
160
  this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
@@ -214,8 +308,20 @@ var MetadataClient = class _MetadataClient {
214
308
  * {@link get} is deliberate and long-standing (the draft envelope's identity
215
309
  * and protection carriers are part of what a draft reader inspects), so it
216
310
  * is preserved by reading the transport directly instead of going through
217
- * `get()`'s unwrap. `unwrapDraftBody` (app-shell) and `unwrapViewDraft`
218
- * (this package) are the shared helpers for taking the body out.
311
+ * `get()`'s unwrap.
312
+ *
313
+ * ⛔ Do NOT hand `.item` to a gate or a write yourself: the body this serves
314
+ * is DECORATED (`_draft`, then `_diagnostics` from `decorateMetadataItem`),
315
+ * and the spec says a served body is not a valid input to the schema that
316
+ * produced it until those come off. {@link extractDraftBody} is the one
317
+ * reader for this method's answer — it takes the presence verdict and then
318
+ * the strip, in that order. That function used to be copy-pasted into four
319
+ * views and only one copy knew the rule (objectui#8181).
320
+ *
321
+ * Its tolerant siblings exist for the OTHER client method: `unwrapDraftBody`
322
+ * (app-shell) and `unwrapViewDraft` (this package) read a draft that arrived
323
+ * through {@link get}, which already unwrapped the envelope. They split on
324
+ * which method feeds them, not on what they mean — all three strip.
219
325
  */
220
326
  async getDraft(type, name, options = {}) {
221
327
  return this.readItemResponse(type, name, {
@@ -223,6 +329,31 @@ var MetadataClient = class _MetadataClient {
223
329
  ...options.packageId ? { packageId: options.packageId } : {}
224
330
  });
225
331
  }
332
+ /**
333
+ * Report the runtime authoring gate's advisory findings for a write the
334
+ * server has ALREADY committed (#4133 for the save door, #5026 for the
335
+ * publish door).
336
+ *
337
+ * Everything here is best-effort by construction, and that is the whole
338
+ * contract: the row is committed server-side before this runs, so neither a
339
+ * malformed body, nor a missing key, nor a throwing sink may change what the
340
+ * calling method returns or whether it throws. The server emits `advisories`
341
+ * ONLY when non-empty, so a clean write costs one absent-key check.
342
+ *
343
+ * One helper rather than a copy per door: every door's response declares the
344
+ * key identically, so a second inline copy could only ever drift. The batch
345
+ * door (`ce986aafc`) calls it once per `published[]` element, handing it
346
+ * the element — the object that carries the key there — and the same
347
+ * best-effort contract covers that loop unchanged.
348
+ */
349
+ emitAdvisories(body, event) {
350
+ if (!this.onSaveAdvisory) return;
351
+ try {
352
+ const advisories = readSaveAdvisories(body);
353
+ if (advisories.length > 0) this.onSaveAdvisory({ ...event, advisories });
354
+ } catch {
355
+ }
356
+ }
226
357
  /**
227
358
  * Save (PUT) a metadata item. The framework accepts both the bare
228
359
  * item payload and the `{ item: ... }` / `{ metadata: ... }`
@@ -235,6 +366,7 @@ var MetadataClient = class _MetadataClient {
235
366
  `MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
236
367
  );
237
368
  }
369
+ assertObjectMetadataWritable(type, item, "MetadataClient.save");
238
370
  const params = [];
239
371
  if (options.force) params.push("force=true");
240
372
  if (options.mode === "draft") params.push("mode=draft");
@@ -246,7 +378,6 @@ var MetadataClient = class _MetadataClient {
246
378
  "Content-Type": "application/json"
247
379
  };
248
380
  if (options.ifMatch) headers["If-Match"] = options.ifMatch;
249
- if (options.actor) headers["X-Actor"] = options.actor;
250
381
  const res = await this.fetchImpl(url, {
251
382
  method: "PUT",
252
383
  headers,
@@ -254,20 +385,12 @@ var MetadataClient = class _MetadataClient {
254
385
  });
255
386
  if (!res.ok) throw await parseError(res);
256
387
  const body = await res.json();
257
- if (this.onSaveAdvisory) {
258
- try {
259
- const advisories = readSaveAdvisories(body);
260
- if (advisories.length > 0) {
261
- this.onSaveAdvisory({
262
- type,
263
- name,
264
- mode: options.mode === "draft" ? "draft" : "publish",
265
- advisories
266
- });
267
- }
268
- } catch {
269
- }
270
- }
388
+ this.emitAdvisories(body, {
389
+ type,
390
+ name,
391
+ door: "save",
392
+ mode: options.mode === "draft" ? "draft" : "publish"
393
+ });
271
394
  return body;
272
395
  }
273
396
  /**
@@ -281,6 +404,61 @@ var MetadataClient = class _MetadataClient {
281
404
  * the rows and reports under `seedApplied` — a data problem never fails the
282
405
  * publish, so callers should check `seedApplied?.success` and warn the user
283
406
  * rather than assume the data went live.
407
+ *
408
+ * Same door as {@link publish} (`POST /meta/:type/:name/publish`), so it
409
+ * reports the gate's advisories the same way (#5026).
410
+ *
411
+ * The BATCH door (`POST /packages/:id/publish-drafts`) is a different route
412
+ * with a method of its own — {@link publishPackageDrafts} — and it reports
413
+ * too. It did not when this paragraph was first written: it discarded
414
+ * per-draft advisories server-side, which was objectstack#9343, and that card
415
+ * has since landed with a ruling that each `published[]` element carries
416
+ * them (`ce986aafc` is the client half).
417
+ *
418
+ * What survives that change is the READING RULE, which is about this method
419
+ * rather than about the other route: it reads the top level of the
420
+ * single-item body and nothing else. A batch-shaped body arriving HERE still
421
+ * reports nothing, because `PublishMetaItemResponse` declares no
422
+ * `published[]` and a client that went looking for findings wherever they
423
+ * might be would be inventing a contract instead of reading one.
424
+ *
425
+ * ## Why there is no `{ success, data }` unwrapping here (`7b433197d`)
426
+ *
427
+ * This method used to unwrap a dispatcher-shaped envelope before returning,
428
+ * while {@link publish} — the same route, a couple of hundred lines down —
429
+ * did not. Two spellings of one wire contract, and nothing said which was
430
+ * right. The tolerance is gone because the route was MEASURED, at the
431
+ * producer rather than inferred from the response schema:
432
+ *
433
+ * - The only mount of `POST /api/v1/meta/:type/:name/publish` is the REST
434
+ * server's own route-manager registration, which answers
435
+ * `res.json(await protocol.publishMetaItem(...))` — the protocol's object
436
+ * verbatim, with no envelope branch. The project-scoped mirror
437
+ * (`/api/v1/environments/:environmentId/...`) re-mounts that same handler.
438
+ * - The `{ success, data }` envelope has exactly one producer, the runtime
439
+ * `HttpDispatcher`, and it does not serve this route: its `/meta` domain
440
+ * has no publish branch, the dispatcher's own route ledger has no row for
441
+ * it, and a three-segment `/meta` path that is not `/published` terminates
442
+ * in a located `routeNotFound`. So no composition — REST server, the
443
+ * dispatcher-only Hono adapter, or both — puts an envelope on this body.
444
+ * - `packages/spec` states the same split in the two docstrings side by
445
+ * side: `PublishMetaItemResponseSchema` describes "the FULL body" this
446
+ * route returns and records that the REST route hands the producer's
447
+ * object to `res.json()` verbatim, while its batch sibling
448
+ * `PublishPackageDraftsResponseSchema` describes a body answered
449
+ * explicitly "inside the dispatcher's `{ success, data }` envelope".
450
+ *
451
+ * ⛔ So an enveloped body on THIS route is not a shape the platform emits,
452
+ * and unwrapping one is not a kindness: it is Commandment #0.1's lenient
453
+ * fallback, a second de-facto contract for a route that declares one. It
454
+ * also fails in the direction that hides the problem — a body that arrived
455
+ * enveloped is one this door did not actually serve, and unwrapping it
456
+ * would present that as a successful promotion.
457
+ *
458
+ * ⚠️ Sibling tolerances in this file are NOT this defect and must stay:
459
+ * {@link listDrafts} reads `data?.data?.drafts` because `GET /meta/_drafts`
460
+ * IS a dispatcher route, and `publishHealthFromResponse` unwraps because the
461
+ * batch door really is enveloped. The rule is per-route, not per-file.
284
462
  */
285
463
  async publishDraft(type, name) {
286
464
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
@@ -291,24 +469,122 @@ var MetadataClient = class _MetadataClient {
291
469
  });
292
470
  if (!res.ok) throw await parseError(res);
293
471
  const body = await res.json().catch(() => ({}));
294
- const inner = body?.data && typeof body.data === "object" ? body.data : body;
295
- return inner;
472
+ this.emitAdvisories(body, { type, name, door: "publish", mode: "publish" });
473
+ return body;
296
474
  }
297
475
  /**
298
- * Get the 3-state layered view of a metadata item (Phase 3a). Returns
299
- * `code` (the artifact / fallback default), `overlay` (the saved
300
- * customisation, if any), and `effective` (what the runtime sees).
476
+ * Publish EVERY pending draft bound to one package — Studio's "publish whole
477
+ * app" door, `POST /api/v1/packages/:id/publish-drafts` (ADR-0033; the route
478
+ * that orders structure-before-seeds server-side and runs the ADR-0038 L3
479
+ * runtime probes).
480
+ *
481
+ * ## Why it is expressed here at all (`ce986aafc`)
482
+ *
483
+ * It was not, and that was the defect. Two app-shell call sites fired this
484
+ * route with a bare `fetch` / a page-private `apiJson`, outside the seam that
485
+ * covers every other metadata write — so when objectstack#9343 landed and the
486
+ * server began sending per-draft advisories, the author publishing a whole
487
+ * app was told nothing, while the SAME button's client-side capability lint
488
+ * still raised a toast. A door that cannot report is not a door the gate can
489
+ * reach, however loudly the server speaks.
490
+ *
491
+ * ## What it reports, and what it refuses to invent
492
+ *
493
+ * `PublishPackageDraftsResponseSchema` declares `advisories` on EACH
494
+ * `published[]` element — objectstack#9343's ruled shape, the same element
495
+ * type and the same omitted-when-empty discipline as the single-item door,
496
+ * and explicitly not a parallel top-level map. So this method emits one
497
+ * {@link MetadataSaveAdvisoryEvent} per advised element, each naming that
498
+ * element's own `type` / `name`, through the same sink, event and renderer
499
+ * the save and single-item publish doors use.
500
+ *
501
+ * ⛔ It renders only what the server sent where the schema says it sits:
502
+ *
503
+ * - `advisories` is read off the element, by the one shared reader, which
504
+ * drops anything that is not a complete finding.
505
+ * - An element that does not carry the `type` and `name` the schema states as
506
+ * REQUIRED is skipped rather than reported under invented identity — the
507
+ * event names the item the author has to go fix, and an event that cannot
508
+ * name it truthfully is worse than silence.
509
+ * - Nothing is derived, counted or summarised from the batch: no advisory
510
+ * exists here that the server did not put in the body.
511
+ *
512
+ * ## The failure shapes stay the callers'
513
+ *
514
+ * Non-2xx throws {@link MetadataError}, like every other method. A 2xx is
515
+ * RETURNED unexamined — `success: false` is not a failure on this route
516
+ * (`outcome: 'nothing_to_publish'` answers it too, and the spec's own text
517
+ * says to read `outcome`, not the boolean), and the two callers have their
518
+ * own, different rules for the refusal and rolled-back cases. Deciding that
519
+ * here would change behaviour this card is not about.
520
+ */
521
+ async publishPackageDrafts(packageId) {
522
+ if (!packageId || !String(packageId).trim()) {
523
+ throw new Error(
524
+ "MetadataClient.publishPackageDrafts: packageId must be non-empty. The POST /packages/:id/publish-drafts route requires an id segment."
525
+ );
526
+ }
527
+ const url = `${packagesBaseOf(this.base)}/${encodeURIComponent(packageId)}/publish-drafts`;
528
+ const res = await this.fetchImpl(url, {
529
+ method: "POST",
530
+ // Both call sites this replaces sent the session cookie, and the client's
531
+ // own fetch adds the console's Bearer token — so the routed call carries a
532
+ // superset of what the bare doors carried, never less.
533
+ credentials: "include",
534
+ headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
535
+ body: "{}"
536
+ });
537
+ if (!res.ok) throw await parseError(res);
538
+ const body = unwrapDispatcherEnvelope(await res.json().catch(() => ({})));
539
+ for (const element of Array.isArray(body.published) ? body.published : []) {
540
+ if (!element || typeof element !== "object") continue;
541
+ const { type, name } = element;
542
+ if (typeof type !== "string" || typeof name !== "string") continue;
543
+ this.emitAdvisories(element, { type, name, door: "publish", mode: "publish" });
544
+ }
545
+ return body;
546
+ }
547
+ /**
548
+ * Get the 3-state layered view of a metadata item: `code` (the packaged
549
+ * artifact baseline), `overlay` (the tenant customisation row alone) and
550
+ * `effective` (the merged value the runtime sees).
551
+ *
552
+ * Reads **`GET /meta/:type/:name/layers`** — the path the framework declares
553
+ * for this projection, with a response schema of its own
554
+ * (`GetMetaItemLayeredResponseSchema`, objectstack#5882 ruling B). It used to
555
+ * be reached by hanging a `layers` flag on the ordinary item read, which made
556
+ * one route answer two unrelated representations while `packages/spec`
557
+ * declared only one of them. That spelling still answers this same body
558
+ * inside its deprecation window (the response carries RFC 9745
559
+ * `Deprecation: true` and an RFC 8288 `Link: rel="successor-version"` back to
560
+ * this path) and is scheduled for removal upstream, so nothing here may
561
+ * depend on it — the repo-wide ratchet is
562
+ * `scripts/__tests__/layered-read-declared-path-4016.test.ts`.
563
+ *
564
+ * The request is built here rather than delegated to `@objectstack/client`
565
+ * because the SDK expresses no layered read in EITHER spelling: the
566
+ * framework's REST route ledger records this route as `server-only`,
567
+ * "consumed by objectui over plain HTTP", and whether the SDK should express
568
+ * it is an open upstream product call.
569
+ *
570
+ * One behaviour delta rides along with the path, and it is the server's
571
+ * choice rather than ours: the retired flag FELL THROUGH to the plain item
572
+ * read on a backend whose protocol implementation had no layered support,
573
+ * answering the `{ type, name, item }` envelope. A dedicated path refuses to
574
+ * answer a different resource under this one's declared shape, so it returns
575
+ * 501 `NOT_IMPLEMENTED` instead — which surfaces here as a thrown error
576
+ * rather than a view with `code` and `overlay` silently blank.
301
577
  */
302
578
  async layered(type, name, options = {}) {
303
- const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : "";
304
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
579
+ const qs = options.packageId ? `?package=${encodeURIComponent(options.packageId)}` : "";
580
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/layers${qs}`;
305
581
  const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
306
582
  if (res.status === 404) {
307
583
  return { code: null, overlay: null, overlayScope: null, effective: null };
308
584
  }
309
585
  if (!res.ok) throw await parseError(res);
310
586
  const body = await res.json();
311
- const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
587
+ const hasEnvelope = typeof body === "object" && body !== null && ("code" in body || "overlay" in body || "effective" in body);
312
588
  if (!hasEnvelope) {
313
589
  return {
314
590
  code: null,
@@ -317,14 +593,34 @@ var MetadataClient = class _MetadataClient {
317
593
  effective: body
318
594
  };
319
595
  }
596
+ const parsed = GetMetaItemLayeredResponseSchema.safeParse(body);
597
+ if (parsed.success) {
598
+ const envelope = parsed.data;
599
+ return {
600
+ code: envelope.code ?? null,
601
+ overlay: envelope.overlay ?? null,
602
+ overlayScope: envelope.overlayScope,
603
+ effective: envelope.effective ?? null,
604
+ ...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
605
+ lock: envelope.lock,
606
+ ...envelope.lockReason !== void 0 ? { lockReason: envelope.lockReason } : {},
607
+ ...envelope.lockSource !== void 0 ? { lockSource: envelope.lockSource } : {},
608
+ ...envelope.lockDocsUrl !== void 0 ? { lockDocsUrl: envelope.lockDocsUrl } : {},
609
+ ...envelope.provenance !== void 0 ? { provenance: envelope.provenance } : {},
610
+ ...envelope.packageId !== void 0 ? { packageId: envelope.packageId } : {},
611
+ ...envelope.packageVersion !== void 0 ? { packageVersion: envelope.packageVersion } : {},
612
+ editable: envelope.editable,
613
+ deletable: envelope.deletable,
614
+ resettable: envelope.resettable
615
+ };
616
+ }
617
+ const unrecognized = unrecognizedEnvelopeKeys(body);
320
618
  return {
321
619
  code: body.code ?? null,
322
620
  overlay: body.overlay ?? null,
323
621
  overlayScope: body.overlayScope ?? null,
324
622
  effective: body.effective ?? null,
325
623
  ...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
326
- // ADR-0010 Phase 4 — pass through the metadata-protection envelope so
327
- // the editor can render lock affordances without a second round trip.
328
624
  ...body.lock !== void 0 ? { lock: body.lock } : {},
329
625
  ...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
330
626
  ...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
@@ -334,7 +630,8 @@ var MetadataClient = class _MetadataClient {
334
630
  ...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
335
631
  ...body.editable !== void 0 ? { editable: body.editable } : {},
336
632
  ...body.deletable !== void 0 ? { deletable: body.deletable } : {},
337
- ...body.resettable !== void 0 ? { resettable: body.resettable } : {}
633
+ ...body.resettable !== void 0 ? { resettable: body.resettable } : {},
634
+ ...unrecognized.length > 0 ? { _unrecognized: unrecognized } : {}
338
635
  };
339
636
  }
340
637
  /**
@@ -391,7 +688,6 @@ var MetadataClient = class _MetadataClient {
391
688
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
392
689
  const headers = { ...this.headers };
393
690
  if (options.ifMatch) headers["If-Match"] = options.ifMatch;
394
- if (options.actor) headers["X-Actor"] = options.actor;
395
691
  const res = await this.fetchImpl(url, { method: "DELETE", headers });
396
692
  if (!res.ok) throw await parseError(res);
397
693
  return await res.json();
@@ -402,21 +698,29 @@ var MetadataClient = class _MetadataClient {
402
698
  * `{ success, version, seq, message }`. Throws a `404 no_draft` if
403
699
  * nothing is pending and `409 metadata_conflict` if the published
404
700
  * overlay moved while the draft was sitting.
701
+ *
702
+ * Reports the runtime authoring gate's advisory findings through
703
+ * {@link MetadataClientConfig.onSaveAdvisory} when the response carries them
704
+ * (#5026 / objectstack#9176), with `door: 'publish'`. This is the door the
705
+ * Studio designer takes on every edit, so it is the one whose findings an
706
+ * author actually sees.
405
707
  */
406
708
  async publish(type, name, options = {}) {
407
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
709
+ const qs = options.packageId ? `?package=${encodeURIComponent(options.packageId)}` : "";
710
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish${qs}`;
408
711
  const headers = {
409
712
  ...this.headers,
410
713
  "Content-Type": "application/json"
411
714
  };
412
- if (options.actor) headers["X-Actor"] = options.actor;
413
715
  const res = await this.fetchImpl(url, {
414
716
  method: "POST",
415
717
  headers,
416
718
  body: JSON.stringify(options.message ? { message: options.message } : {})
417
719
  });
418
720
  if (!res.ok) throw await parseError(res);
419
- return await res.json();
721
+ const body = await res.json();
722
+ this.emitAdvisories(body, { type, name, door: "publish", mode: "publish" });
723
+ return body;
420
724
  }
421
725
  /**
422
726
  * Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
@@ -432,7 +736,6 @@ var MetadataClient = class _MetadataClient {
432
736
  ...this.headers,
433
737
  "Content-Type": "application/json"
434
738
  };
435
- if (options.actor) headers["X-Actor"] = options.actor;
436
739
  const res = await this.fetchImpl(url, {
437
740
  method: "POST",
438
741
  headers,
@@ -512,15 +815,38 @@ function errorCodeIsAnyOf(err, codes) {
512
815
  }
513
816
 
514
817
  // ../core/src/utils/filter-converter.ts
515
- import { normalizeFilterOperator } from "@objectstack/spec/ui";
818
+ import {
819
+ normalizeFilterOperator,
820
+ VIEW_FILTER_OPERATORS,
821
+ VIEW_FILTER_LIST_VALUE_OPERATORS,
822
+ VIEW_FILTER_PAIR_VALUE_OPERATORS
823
+ } from "@objectstack/spec/ui";
824
+ import { isAcceptedFilterComparand, ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE } from "@objectstack/spec/data";
825
+
826
+ // ../core/src/utils/text-comparand.ts
827
+ import { isRefusedTextComparand, textComparandRefusalReason } from "@objectstack/spec/data";
828
+
829
+ // ../core/src/utils/filter-converter.ts
516
830
  var FilterOperatorError = class extends Error {
517
- constructor(message) {
831
+ constructor(message, subject) {
518
832
  super(message);
519
833
  __publicField(this, "code", "INVALID_FILTER");
520
834
  __publicField(this, "httpStatus", 400);
835
+ /** @see FilterRefusalSubject */
836
+ __publicField(this, "operator");
837
+ /** @see FilterRefusalSubject */
838
+ __publicField(this, "field");
521
839
  this.name = "FilterOperatorError";
840
+ this.operator = subject?.operator;
841
+ this.field = subject?.field;
522
842
  }
523
843
  };
844
+ var RETIRED_OPERATOR_ALIASES = {
845
+ "$notin": "$nin",
846
+ "$notcontains": "$notContains",
847
+ "$startswith": "$startsWith",
848
+ "$endswith": "$endsWith"
849
+ };
524
850
  function convertOperatorToAST(operator) {
525
851
  const operatorMap = {
526
852
  "$eq": "=",
@@ -531,27 +857,148 @@ function convertOperatorToAST(operator) {
531
857
  "$lte": "<=",
532
858
  "$in": "in",
533
859
  "$nin": "nin",
534
- "$notin": "nin",
535
860
  "$between": "between",
536
861
  "$contains": "contains",
537
862
  "$notContains": "notcontains",
538
- "$notcontains": "notcontains",
539
863
  "$startsWith": "startswith",
540
- "$startswith": "startswith",
541
864
  "$endsWith": "endswith",
542
- "$endswith": "endswith"
865
+ // Case-insensitive contains. A canonical `FILTER_OPERATORS` member that
866
+ // `ValueDataSource` executes and `FilterConditionField` emits (for its
867
+ // `icontains` builder row — spelled `containsCaseInsensitive` until
868
+ // objectui#9306), while this map refused it with the
869
+ // generic unknown-operator paragraph — so ONE authored filter selected rows
870
+ // through the in-memory matcher and 400'd on the ObjectStack lowering path
871
+ // (objectui#8976). The other direction of the same split objectui#8568 fixed:
872
+ // there the map was MORE tolerant than the matcher, here it was LESS tolerant
873
+ // than the contract. Restored by aligning the map with the spec, per AGENTS.md
874
+ // #0 — not by removing the operator from the builder, which the spec declares.
875
+ //
876
+ // The VALUE is an IDENTITY, and that is not a typo: `icontains` is itself a
877
+ // member of the spec's `VALID_AST_OPERATORS`, so unlike its camelCase siblings
878
+ // above there is no case to squash. Same identity row, for the same stated
879
+ // reason, that `FILTER_OPERATOR_ALIASES` carries in
880
+ // `packages/data-objectstack/src/index.ts`.
881
+ "$icontains": "icontains"
543
882
  };
544
883
  return operatorMap[operator] || null;
545
884
  }
885
+ var AST_LOGIC_KEYWORD = {
886
+ $and: "and",
887
+ $or: "or"
888
+ };
889
+ function lowerLogicalGroup(field, keyword, value) {
890
+ if (!Array.isArray(value)) {
891
+ throw new FilterOperatorError(
892
+ `[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).`,
893
+ { operator: field }
894
+ );
895
+ }
896
+ if (value.length === 0) {
897
+ return keyword === "and" ? void 0 : falseIdentityLeaf(field, value);
898
+ }
899
+ const children = [];
900
+ let absorbed = false;
901
+ for (const child of value) {
902
+ if (child === null || typeof child !== "object" || Array.isArray(child)) {
903
+ throw new FilterOperatorError(
904
+ `[ObjectUI] Every member of '${field}' must be a filter condition OBJECT. Received ${JSON.stringify(child)}. Spec: FilterCondition declares '${field}?: FilterCondition[]' (data/filter.zod.ts).`,
905
+ { operator: field }
906
+ );
907
+ }
908
+ const lowered = convertFiltersToAST(child);
909
+ if (!Array.isArray(lowered)) {
910
+ if (keyword === "or") absorbed = true;
911
+ continue;
912
+ }
913
+ children.push(lowered);
914
+ }
915
+ if (absorbed) return void 0;
916
+ if (children.length === 0) return void 0;
917
+ return [keyword, ...children];
918
+ }
919
+ function falseIdentityLeaf(field, value) {
920
+ return [field, "=", value];
921
+ }
922
+ function isOperatorMap(value) {
923
+ const proto = Object.getPrototypeOf(value);
924
+ return proto === null || proto === Object.prototype || Object.getPrototypeOf(proto) === null;
925
+ }
926
+ function describeExoticComparand(value) {
927
+ let name;
928
+ let text2;
929
+ try {
930
+ const proto = Object.getPrototypeOf(value);
931
+ name = proto?.constructor?.name;
932
+ } catch {
933
+ name = void 0;
934
+ }
935
+ if (typeof name !== "string" || name === "") return "an object of an unrecognised type";
936
+ try {
937
+ text2 = String(value);
938
+ } catch {
939
+ text2 = void 0;
940
+ }
941
+ return text2 && text2 !== `[object ${name}]` ? `a ${name} instance (${text2})` : `a ${name} instance`;
942
+ }
943
+ function refuseTextComparand(field, operator, target) {
944
+ const ported = `It is refused here rather than lowered onto the wire (objectui#9001; ported from ValueDataSource's refuseTextComparand, objectui#8748).`;
945
+ throw new FilterOperatorError(
946
+ `[ObjectUI] The ${textComparandRefusalReason(field, operator, target)}. ${ported}`,
947
+ { operator, field }
948
+ );
949
+ }
950
+ function refuseEmptyOperatorMap(field) {
951
+ throw new FilterOperatorError(
952
+ `[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.`,
953
+ { field }
954
+ );
955
+ }
546
956
  function convertFiltersToAST(filter) {
547
957
  const conditions = [];
958
+ let trueIdentityGroups = 0;
959
+ let skippedNullKeys = 0;
548
960
  for (const [field, value] of Object.entries(filter)) {
549
- if (value === null || value === void 0) continue;
961
+ if (value === null || value === void 0) {
962
+ skippedNullKeys += 1;
963
+ continue;
964
+ }
965
+ const logicKeyword = AST_LOGIC_KEYWORD[field];
966
+ if (logicKeyword) {
967
+ const group = lowerLogicalGroup(field, logicKeyword, value);
968
+ if (group !== void 0) conditions.push(group);
969
+ else trueIdentityGroups += 1;
970
+ continue;
971
+ }
972
+ if (field === "$not") {
973
+ throw new FilterOperatorError(
974
+ `[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)}.`,
975
+ { operator: "$not" }
976
+ );
977
+ }
978
+ if (Array.isArray(value)) {
979
+ throw new FilterOperatorError(
980
+ `[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] } }.`,
981
+ { field }
982
+ );
983
+ }
550
984
  if (typeof value === "object" && !Array.isArray(value)) {
551
- for (const [operator, operatorValue] of Object.entries(value)) {
985
+ if (isAcceptedFilterComparand(value)) {
986
+ conditions.push([field, "=", value]);
987
+ continue;
988
+ }
989
+ if (!isOperatorMap(value)) {
990
+ throw new FilterOperatorError(
991
+ `[ObjectUI] The filter on field '${field}' carries ${describeExoticComparand(value)} in comparand position. '@objectstack/spec' does not accept it: a comparison value must be ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (ACCEPTED_FILTER_COMPARAND_TYPES), so isAcceptedFilterComparand is false for it and normalizeFilterComparandTypes answers 400 INVALID_FILTER \u2014 "which no driver can compare". It is not an operator map either: only a plain object can be one, so until objectui#8567 this value was read as ZERO operators and the '${field}' condition was dropped from the filter entirely, silently WIDENING the result set. Spell a text match as { ${field}: { $contains: '...' } } ($startsWith / $endsWith; a pattern itself has no operator here, see the $regex refusal), a membership test as { ${field}: { $in: [...] } }, and a date bound as { ${field}: { $gte: new Date(...) } }.`,
992
+ { field }
993
+ );
994
+ }
995
+ const operatorEntries = Object.entries(value);
996
+ if (operatorEntries.length === 0) refuseEmptyOperatorMap(field);
997
+ for (const [operator, operatorValue] of operatorEntries) {
552
998
  if (operator === "$regex") {
553
999
  throw new FilterOperatorError(
554
- `[ObjectUI] The '$regex' filter operator is not supported. It used to be converted to 'contains', which matches a literal substring rather than a pattern \u2014 a different result, not a degraded one. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. Use $contains, $startsWith or $endsWith.`
1000
+ `[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.`,
1001
+ { operator: "$regex", field }
555
1002
  );
556
1003
  }
557
1004
  if (operator === "$null") {
@@ -562,12 +1009,33 @@ function convertFiltersToAST(filter) {
562
1009
  conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
563
1010
  continue;
564
1011
  }
1012
+ if (operator === "$empty") {
1013
+ if (operatorValue !== true && operatorValue !== false) {
1014
+ throw new FilterOperatorError(
1015
+ `[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.`,
1016
+ { operator: "$empty", field }
1017
+ );
1018
+ }
1019
+ conditions.push([field, operatorValue ? "is_empty" : "is_not_empty", true]);
1020
+ continue;
1021
+ }
565
1022
  const astOperator = convertOperatorToAST(operator);
566
1023
  if (astOperator) {
1024
+ if (astOperator === "icontains" && isRefusedTextComparand(operatorValue)) {
1025
+ refuseTextComparand(field, operator, operatorValue);
1026
+ }
567
1027
  conditions.push([field, astOperator, operatorValue]);
568
1028
  } else {
1029
+ const canonical = RETIRED_OPERATOR_ALIASES[operator];
1030
+ if (canonical) {
1031
+ throw new FilterOperatorError(
1032
+ `[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).`,
1033
+ { operator, field }
1034
+ );
1035
+ }
569
1036
  throw new FilterOperatorError(
570
- `[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, $notContains, $startsWith, $endsWith, $null, $exists. If you need exact object matching, use the value directly without an operator.`
1037
+ `[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.`,
1038
+ { operator, field }
571
1039
  );
572
1040
  }
573
1041
  }
@@ -576,6 +1044,13 @@ function convertFiltersToAST(filter) {
576
1044
  }
577
1045
  }
578
1046
  if (conditions.length === 0) {
1047
+ const keysTheLoopProcessed = Object.keys(filter).length - skippedNullKeys;
1048
+ if (trueIdentityGroups > 0 && trueIdentityGroups === keysTheLoopProcessed) {
1049
+ return void 0;
1050
+ }
1051
+ if (skippedNullKeys > 0 && skippedNullKeys === Object.keys(filter).length) {
1052
+ return void 0;
1053
+ }
579
1054
  return filter;
580
1055
  }
581
1056
  if (conditions.length === 1) {
@@ -583,6 +1058,52 @@ function convertFiltersToAST(filter) {
583
1058
  }
584
1059
  return ["and", ...conditions];
585
1060
  }
1061
+ var ARRAY_VALUED_VIEW_OPERATORS = /* @__PURE__ */ new Set([
1062
+ ...VIEW_FILTER_LIST_VALUE_OPERATORS,
1063
+ ...VIEW_FILTER_PAIR_VALUE_OPERATORS
1064
+ ]);
1065
+ var KNOWN_VIEW_OPERATORS = new Set(VIEW_FILTER_OPERATORS);
1066
+
1067
+ // ../core/src/utils/column-sortability.ts
1068
+ import {
1069
+ FIELD_UNSORTABLE_VIRTUAL_TYPE,
1070
+ FIELD_SORTABLE_UNPROVISIONED_ANCHOR
1071
+ } from "@objectstack/spec/api";
1072
+ var OBJECT_SORTABILITY_KEY = /* @__PURE__ */ Symbol.for("objectui.objectSortability");
1073
+ function asFieldSortability(value) {
1074
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
1075
+ const sortable = value.sortable;
1076
+ if (typeof sortable !== "boolean") return void 0;
1077
+ const reason = value.reason;
1078
+ const caveat = value.caveat;
1079
+ return {
1080
+ sortable,
1081
+ ...typeof reason === "string" ? { reason } : {},
1082
+ ...typeof caveat === "string" ? { caveat } : {}
1083
+ };
1084
+ }
1085
+ function normalizeObjectSortability(served) {
1086
+ if (!served || typeof served !== "object" || Array.isArray(served)) return void 0;
1087
+ const rawFields = served.fields;
1088
+ if (!rawFields || typeof rawFields !== "object" || Array.isArray(rawFields)) return void 0;
1089
+ const fields = {};
1090
+ for (const [name, entry] of Object.entries(rawFields)) {
1091
+ const verdict = asFieldSortability(entry);
1092
+ if (verdict) fields[name] = verdict;
1093
+ }
1094
+ return { fields };
1095
+ }
1096
+ function attachObjectSortability(schema, served) {
1097
+ const projection = normalizeObjectSortability(served);
1098
+ if (!projection || !schema || typeof schema !== "object") return schema;
1099
+ Object.defineProperty(schema, OBJECT_SORTABILITY_KEY, {
1100
+ value: projection,
1101
+ enumerable: false,
1102
+ configurable: true,
1103
+ writable: true
1104
+ });
1105
+ return schema;
1106
+ }
586
1107
 
587
1108
  // ../core/src/adapters/batchTransaction.ts
588
1109
  function idOf(rec) {
@@ -651,20 +1172,143 @@ async function emulateBatchTransaction(dataSource, operations) {
651
1172
  }
652
1173
 
653
1174
  // ../core/src/utils/reference-keys.ts
654
- function normalizeFieldReferenceKeys(fieldDef) {
1175
+ import { FIELD_KEY_GUIDANCE, FieldSchema } from "@objectstack/spec/data";
1176
+ var aliasProbe = (key) => key.toLowerCase().replace(/[_\-\s]/g, "");
1177
+ var declaredFieldKeys = null;
1178
+ var probeFolds = null;
1179
+ var collidingProbes = null;
1180
+ function fieldKeyFolds() {
1181
+ if (probeFolds) return probeFolds;
1182
+ const declared = Object.keys(FieldSchema.shape);
1183
+ const seen = /* @__PURE__ */ new Map();
1184
+ for (const key of declared) {
1185
+ const probe = aliasProbe(key);
1186
+ seen.set(probe, [...seen.get(probe) ?? [], key]);
1187
+ }
1188
+ const folds = /* @__PURE__ */ new Map();
1189
+ const collisions = /* @__PURE__ */ new Map();
1190
+ for (const [probe, keys] of seen) {
1191
+ if (keys.length === 1) folds.set(probe, keys[0]);
1192
+ else collisions.set(probe, keys);
1193
+ }
1194
+ declaredFieldKeys = new Set(declared);
1195
+ collidingProbes = collisions;
1196
+ probeFolds = folds;
1197
+ return folds;
1198
+ }
1199
+ var warnedRetiredSpelling = /* @__PURE__ */ new Set();
1200
+ var warnedUnfoldableSpelling = /* @__PURE__ */ new Set();
1201
+ function warnUnfoldableRetiredKey(reason, key, detail, fieldName, objectName, f) {
1202
+ if (!isDev()) return;
1203
+ const named = fieldName ?? (typeof f.name === "string" ? f.name : "(unnamed field)");
1204
+ const owner = objectName ?? "(unknown object)";
1205
+ const memo = `${owner}:${named}:${key}:${reason}`;
1206
+ if (warnedUnfoldableSpelling.has(memo)) return;
1207
+ const published = reason === "no-declared-twin" && key === "id_field" ? ` \`@objectstack/spec\`'s \`FIELD_KEY_GUIDANCE\` gives the reason: ${FIELD_KEY_GUIDANCE.id_field.why}` : "";
1208
+ warnedUnfoldableSpelling.add(memo);
1209
+ console.warn(
1210
+ `[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)`
1211
+ );
1212
+ }
1213
+ function canonicalizeRetiredFieldKeys(f, fieldName, objectName) {
1214
+ const folds = fieldKeyFolds();
1215
+ for (const key of Object.keys(f)) {
1216
+ if (declaredFieldKeys.has(key)) continue;
1217
+ const value = f[key];
1218
+ if (value === void 0) continue;
1219
+ if (REFERENCE_ARM_KEYS.has(key)) continue;
1220
+ const probe = aliasProbe(key);
1221
+ const canonical = folds.get(probe);
1222
+ if (canonical === void 0) {
1223
+ const colliding = collidingProbes.get(probe);
1224
+ if (colliding) {
1225
+ warnUnfoldableRetiredKey(
1226
+ "ambiguous-probe",
1227
+ key,
1228
+ `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.`,
1229
+ fieldName,
1230
+ objectName,
1231
+ f
1232
+ );
1233
+ } else {
1234
+ warnUnfoldableRetiredKey(
1235
+ "no-declared-twin",
1236
+ key,
1237
+ `\`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.`,
1238
+ fieldName,
1239
+ objectName,
1240
+ f
1241
+ );
1242
+ }
1243
+ continue;
1244
+ }
1245
+ if (f[canonical] !== void 0) {
1246
+ if (f[canonical] !== value) {
1247
+ warnUnfoldableRetiredKey(
1248
+ "canonical-occupied",
1249
+ key,
1250
+ `\`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.`,
1251
+ fieldName,
1252
+ objectName,
1253
+ f
1254
+ );
1255
+ }
1256
+ continue;
1257
+ }
1258
+ f[canonical] = value;
1259
+ if (!isDev()) continue;
1260
+ const named = fieldName ?? (typeof f.name === "string" ? f.name : "(unnamed field)");
1261
+ const owner = objectName ?? "(unknown object)";
1262
+ const memo = `${owner}:${named}:${key}:${canonical}`;
1263
+ if (warnedRetiredSpelling.has(memo)) continue;
1264
+ warnedRetiredSpelling.add(memo);
1265
+ console.warn(
1266
+ `[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)`
1267
+ );
1268
+ }
1269
+ }
1270
+ var warnedLegacyOnly = /* @__PURE__ */ new Set();
1271
+ var isDev = () => globalThis.process?.env?.NODE_ENV !== "production";
1272
+ var LEGACY_REFERENCE_KEYS = ["reference_to", "referenceTo"];
1273
+ var REFERENCE_ARM_KEYS = new Set(LEGACY_REFERENCE_KEYS);
1274
+ function warnOnLegacyOnlyReference(f, fieldName, objectName) {
1275
+ if (!isDev()) return;
1276
+ if (f.reference !== void 0) return;
1277
+ const spelled = LEGACY_REFERENCE_KEYS.filter((k) => f[k] !== void 0 && f[k] !== "");
1278
+ if (spelled.length === 0) return;
1279
+ const named = fieldName ?? (typeof f.name === "string" ? f.name : "(unnamed field)");
1280
+ const owner = objectName ?? "(unknown object)";
1281
+ for (const key of spelled) {
1282
+ const memo = `${owner}:${named}:${key}:${String(f[key])}`;
1283
+ if (warnedLegacyOnly.has(memo)) continue;
1284
+ warnedLegacyOnly.add(memo);
1285
+ console.warn(
1286
+ `[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)`
1287
+ );
1288
+ }
1289
+ }
1290
+ function normalizeFieldReferenceKeys(fieldDef, fieldName, objectName) {
655
1291
  if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
656
1292
  const f = fieldDef;
657
- const target = f.reference_to ?? f.reference ?? f.referenceTo;
658
- if (target == null || target === "") return fieldDef;
659
- if (f.reference_to === void 0) f.reference_to = target;
660
- if (f.reference === void 0) f.reference = target;
1293
+ canonicalizeRetiredFieldKeys(f, fieldName, objectName);
1294
+ if (f.reference !== void 0) return fieldDef;
1295
+ const legacy = f.reference_to ?? f.referenceTo;
1296
+ if (legacy == null || legacy === "") return fieldDef;
1297
+ warnOnLegacyOnlyReference(f, fieldName, objectName);
1298
+ f.reference = legacy;
661
1299
  return fieldDef;
662
1300
  }
663
1301
  function normalizeSchemaReferenceKeys(schema) {
664
1302
  const fields = schema && typeof schema === "object" ? schema.fields : null;
665
1303
  if (!fields || typeof fields !== "object") return schema;
666
- const defs = Array.isArray(fields) ? fields : Object.values(fields);
667
- for (const def of defs) normalizeFieldReferenceKeys(def);
1304
+ const objectName = typeof schema.name === "string" ? schema.name : void 0;
1305
+ if (Array.isArray(fields)) {
1306
+ for (const def of fields) normalizeFieldReferenceKeys(def, void 0, objectName);
1307
+ } else {
1308
+ for (const [name, def] of Object.entries(fields)) {
1309
+ normalizeFieldReferenceKeys(def, name, objectName);
1310
+ }
1311
+ }
668
1312
  return schema;
669
1313
  }
670
1314
 
@@ -1037,50 +1681,23 @@ function isErrorType(error, errorClass) {
1037
1681
  return error instanceof errorClass;
1038
1682
  }
1039
1683
 
1040
- // src/contracts.ts
1041
- function validatePluginContract(contract) {
1042
- const errors = [];
1043
- const warnings = [];
1044
- if (!contract.name || contract.name.trim().length === 0) {
1045
- errors.push({ field: "name", message: "Plugin name is required", code: "MISSING_NAME" });
1046
- }
1047
- if (!contract.version || !/^\d+\.\d+\.\d+/.test(contract.version)) {
1048
- errors.push({ field: "version", message: "Valid semver version is required", code: "INVALID_VERSION" });
1049
- }
1050
- if (!contract.exports || contract.exports.length === 0) {
1051
- errors.push({ field: "exports", message: "At least one export is required", code: "NO_EXPORTS" });
1052
- }
1053
- if (contract.exports) {
1054
- const validTypes = ["component", "hook", "utility", "provider"];
1055
- for (const exp of contract.exports) {
1056
- if (!exp.name) {
1057
- errors.push({ field: "exports.name", message: "Export name is required", code: "MISSING_EXPORT_NAME" });
1058
- }
1059
- if (!validTypes.includes(exp.type)) {
1060
- errors.push({ field: "exports.type", message: `Invalid export type: ${exp.type}`, code: "INVALID_EXPORT_TYPE" });
1061
- }
1062
- }
1063
- }
1064
- if (!contract.permissions || contract.permissions.length === 0) {
1065
- warnings.push("No permissions declared \u2014 plugin will have minimal access");
1066
- }
1067
- return { valid: errors.length === 0, errors, warnings };
1684
+ // src/error-envelope.ts
1685
+ function readErrorEnvelope(body) {
1686
+ if (!body || typeof body !== "object") return {};
1687
+ const env = body;
1688
+ const nested = env.error && typeof env.error === "object" ? env.error : void 0;
1689
+ const reading = {};
1690
+ const message = text(nested?.message) ?? text(env.message) ?? text(env.error);
1691
+ if (message !== void 0) reading.message = message;
1692
+ const code = present(env.code) ?? present(nested?.code);
1693
+ if (code !== void 0) reading.code = code;
1694
+ return reading;
1068
1695
  }
1069
- function generateContractManifest(contract) {
1070
- return {
1071
- $schema: "https://objectui.org/schemas/plugin-contract-v1.json",
1072
- name: contract.name,
1073
- version: contract.version,
1074
- peerDependencies: contract.peerDependencies ?? {},
1075
- exports: contract.exports.map((exp) => ({
1076
- name: exp.name,
1077
- type: exp.type,
1078
- description: exp.description ?? ""
1079
- })),
1080
- permissions: contract.permissions ?? [],
1081
- api: contract.api ?? {},
1082
- generatedAt: (/* @__PURE__ */ new Date()).toISOString()
1083
- };
1696
+ function text(value) {
1697
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
1698
+ }
1699
+ function present(value) {
1700
+ return typeof value === "string" ? value : void 0;
1084
1701
  }
1085
1702
 
1086
1703
  // src/userState.ts
@@ -1185,265 +1802,31 @@ function createObjectStackUserStateAdapter(options) {
1185
1802
  };
1186
1803
  }
1187
1804
 
1188
- // src/integration.ts
1189
- var IntegrationManager = class {
1190
- constructor() {
1191
- __publicField(this, "integrations", /* @__PURE__ */ new Map());
1192
- }
1193
- /**
1194
- * Register a new integration.
1195
- */
1196
- register(id, config) {
1197
- this.integrations.set(id, config);
1198
- }
1199
- /**
1200
- * Remove an integration.
1201
- */
1202
- unregister(id) {
1203
- this.integrations.delete(id);
1204
- }
1205
- /**
1206
- * Get all registered integrations.
1207
- */
1208
- getAll() {
1209
- return new Map(this.integrations);
1210
- }
1211
- /**
1212
- * Get integrations that match a specific event.
1213
- */
1214
- getForEvent(event) {
1215
- return Array.from(this.integrations.values()).filter(
1216
- (integration) => integration.enabled && integration.triggers?.some((t) => t.event === event)
1217
- );
1218
- }
1219
- /**
1220
- * Dispatch an event to all matching integrations.
1221
- * Returns results for each integration.
1222
- */
1223
- async dispatch(event, payload) {
1224
- const matching = this.getForEvent(event);
1225
- const results = [];
1226
- for (const [id, integration] of this.integrations) {
1227
- if (!matching.includes(integration)) continue;
1228
- try {
1229
- await this.send(integration, payload);
1230
- results.push({ id, success: true });
1231
- } catch (err) {
1232
- results.push({ id, success: false, error: err.message });
1233
- }
1234
- }
1235
- return results;
1236
- }
1237
- /**
1238
- * Send payload to a specific integration.
1239
- */
1240
- async send(integration, payload) {
1241
- switch (integration.provider) {
1242
- case "webhook": {
1243
- const cfg = integration.config;
1244
- const url = cfg.url;
1245
- try {
1246
- const parsed = new URL(url);
1247
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1248
- throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
1249
- }
1250
- } catch (e) {
1251
- if (e instanceof TypeError) {
1252
- throw new Error(`Invalid webhook URL: ${url}`);
1253
- }
1254
- throw e;
1255
- }
1256
- await fetch(url, {
1257
- method: cfg.method,
1258
- headers: {
1259
- "Content-Type": "application/json",
1260
- ...cfg.headers
1261
- },
1262
- body: JSON.stringify(payload)
1263
- });
1264
- break;
1265
- }
1266
- case "slack": {
1267
- const cfg = integration.config;
1268
- const url = cfg.webhookUrl;
1269
- try {
1270
- const parsed = new URL(url);
1271
- if (parsed.protocol !== "https:") {
1272
- throw new Error(`Invalid Slack webhook URL protocol: ${parsed.protocol}`);
1273
- }
1274
- } catch (e) {
1275
- if (e instanceof TypeError) {
1276
- throw new Error(`Invalid Slack webhook URL: ${url}`);
1277
- }
1278
- throw e;
1279
- }
1280
- await fetch(url, {
1281
- method: "POST",
1282
- headers: { "Content-Type": "application/json" },
1283
- body: JSON.stringify({
1284
- channel: cfg.channel,
1285
- username: cfg.username,
1286
- icon_emoji: cfg.iconEmoji,
1287
- text: JSON.stringify(payload)
1288
- })
1289
- });
1290
- break;
1291
- }
1292
- // Email and other providers would require server-side implementation
1293
- default:
1294
- break;
1295
- }
1296
- }
1297
- };
1298
-
1299
- // src/security.ts
1300
- var SecurityManager = class {
1301
- constructor(policy = {}) {
1302
- __publicField(this, "policy");
1303
- __publicField(this, "auditLog", []);
1304
- this.policy = policy;
1305
- }
1306
- /**
1307
- * Generate a CSP header string from the configuration.
1308
- */
1309
- generateCSPHeader() {
1310
- const csp = this.policy.csp;
1311
- if (!csp) return "";
1312
- const directives = [];
1313
- if (csp.scriptSrc?.length) directives.push(`script-src ${csp.scriptSrc.join(" ")}`);
1314
- if (csp.styleSrc?.length) directives.push(`style-src ${csp.styleSrc.join(" ")}`);
1315
- if (csp.imgSrc?.length) directives.push(`img-src ${csp.imgSrc.join(" ")}`);
1316
- if (csp.connectSrc?.length) directives.push(`connect-src ${csp.connectSrc.join(" ")}`);
1317
- if (csp.fontSrc?.length) directives.push(`font-src ${csp.fontSrc.join(" ")}`);
1318
- if (csp.frameSrc?.length) directives.push(`frame-src ${csp.frameSrc.join(" ")}`);
1319
- if (csp.reportUri) directives.push(`report-uri ${csp.reportUri}`);
1320
- return directives.join("; ");
1321
- }
1322
- /**
1323
- * Record an audit log entry.
1324
- */
1325
- recordAudit(entry) {
1326
- if (!this.policy.auditLog?.enabled) return;
1327
- if (this.policy.auditLog.events && !this.policy.auditLog.events.includes(entry.event)) return;
1328
- const fullEntry = {
1329
- ...entry,
1330
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1331
- };
1332
- this.auditLog.push(fullEntry);
1333
- const dest = this.policy.auditLog.destination ?? "console";
1334
- if (dest === "console" || dest === "both") {
1335
- console.info("[AUDIT]", fullEntry.event, fullEntry.userId, fullEntry.resource ?? "");
1336
- }
1337
- }
1338
- /**
1339
- * Get audit log entries.
1340
- */
1341
- getAuditLog(filter) {
1342
- let entries = [...this.auditLog];
1343
- if (filter?.event) entries = entries.filter((e) => e.event === filter.event);
1344
- if (filter?.userId) entries = entries.filter((e) => e.userId === filter.userId);
1345
- if (filter?.since) entries = entries.filter((e) => e.timestamp >= filter.since);
1346
- return entries;
1347
- }
1348
- /**
1349
- * Apply data masking to a record.
1350
- */
1351
- maskRecord(record, userRoles = []) {
1352
- if (!this.policy.dataMasking?.rules?.length) return record;
1353
- const masked = { ...record };
1354
- const maskChar = this.policy.dataMasking.maskChar ?? "*";
1355
- for (const rule of this.policy.dataMasking.rules) {
1356
- if (!(rule.field in masked) || masked[rule.field] == null) continue;
1357
- if (rule.exemptRoles?.some((role) => userRoles.includes(role))) continue;
1358
- const value = String(masked[rule.field]);
1359
- switch (rule.strategy) {
1360
- case "full":
1361
- masked[rule.field] = maskChar.repeat(value.length);
1362
- break;
1363
- case "partial": {
1364
- const visible = rule.visibleChars ?? 4;
1365
- if (value.length <= visible) {
1366
- masked[rule.field] = maskChar.repeat(value.length);
1367
- } else {
1368
- masked[rule.field] = value.slice(0, visible) + maskChar.repeat(value.length - visible);
1369
- }
1370
- break;
1371
- }
1372
- case "hash":
1373
- masked[rule.field] = `[HASHED:${simpleHash(value)}]`;
1374
- break;
1375
- case "redact":
1376
- masked[rule.field] = "[REDACTED]";
1377
- break;
1378
- }
1379
- }
1380
- return masked;
1381
- }
1382
- /**
1383
- * Update the security policy.
1384
- */
1385
- updatePolicy(policy) {
1386
- this.policy = { ...this.policy, ...policy };
1387
- }
1388
- /**
1389
- * Get current security policy.
1390
- */
1391
- getPolicy() {
1392
- return { ...this.policy };
1393
- }
1394
- };
1395
- function simpleHash(str) {
1396
- let hash = 0;
1397
- for (let i = 0; i < str.length; i++) {
1398
- const char = str.charCodeAt(i);
1399
- hash = (hash << 5) - hash + char;
1400
- hash = hash & hash;
1401
- }
1402
- return Math.abs(hash).toString(36);
1805
+ // src/draft-envelope.ts
1806
+ import { stripReadDecorations } from "@objectstack/spec/kernel";
1807
+ function extractDraftBody(resp) {
1808
+ if (!resp || typeof resp !== "object") return null;
1809
+ const env = resp;
1810
+ if (!("item" in env)) return null;
1811
+ const body = env.item;
1812
+ if (!body || typeof body !== "object") return null;
1813
+ if (Object.keys(body).length === 0) return null;
1814
+ return stripReadDecorations(body);
1403
1815
  }
1404
1816
 
1405
- // src/studio.ts
1406
- function createDefaultCanvasConfig(overrides) {
1407
- return {
1408
- width: 1200,
1409
- height: 800,
1410
- background: "grid",
1411
- gridSize: 8,
1412
- snapToGrid: true,
1413
- zoom: {
1414
- min: 0.25,
1415
- max: 3,
1416
- step: 0.1,
1417
- current: 1
1418
- },
1419
- panOffset: { x: 0, y: 0 },
1420
- showMinimap: false,
1421
- minimapPosition: "bottom-right",
1422
- ...overrides
1423
- };
1817
+ // src/metadata-error.ts
1818
+ function extractIssues(e) {
1819
+ const issues = e?.issues;
1820
+ return Array.isArray(issues) ? issues : [];
1424
1821
  }
1425
- function snapToGrid(x, y, gridSize) {
1426
- return {
1427
- x: Math.round(x / gridSize) * gridSize,
1428
- y: Math.round(y / gridSize) * gridSize
1429
- };
1822
+ function formatMetadataIssue(i) {
1823
+ return `\u2022 ${i.path && i.path.length > 0 ? i.path : "(root)"} \u2014 ${i.message}`;
1430
1824
  }
1431
- function calculateAutoLayout(items, canvasWidth, padding = 40, gap = 40) {
1432
- const positions = [];
1433
- let currentX = padding;
1434
- let currentY = padding;
1435
- let rowMaxHeight = 0;
1436
- for (const item of items) {
1437
- if (currentX + item.width + padding > canvasWidth) {
1438
- currentX = padding;
1439
- currentY += rowMaxHeight + gap;
1440
- rowMaxHeight = 0;
1441
- }
1442
- positions.push({ id: item.id, x: currentX, y: currentY });
1443
- currentX += item.width + gap;
1444
- rowMaxHeight = Math.max(rowMaxHeight, item.height);
1445
- }
1446
- return positions;
1825
+ function formatMetadataError(e) {
1826
+ const issues = extractIssues(e);
1827
+ if (issues.length > 0) return issues.map(formatMetadataIssue).join("\n");
1828
+ const err = e;
1829
+ return err?.message ?? String(e);
1447
1830
  }
1448
1831
 
1449
1832
  // src/index.ts
@@ -1473,6 +1856,15 @@ var FILTER_OPERATOR_ALIASES = {
1473
1856
  notin: "nin",
1474
1857
  nin: "nin",
1475
1858
  contains: "contains",
1859
+ // Case-insensitive contains. A canonical `VIEW_FILTER_OPERATORS` member that
1860
+ // arrived with `@objectstack/spec` 17.1.0 (objectui#5328), and an IDENTITY row
1861
+ // like `contains` above: `icontains` is itself a member of
1862
+ // `VALID_AST_OPERATORS`, so the spelling the author writes is the spelling the
1863
+ // AST takes — nothing is translated and no case-sensitivity is lost.
1864
+ // Declared rather than left to the `?? op` fall-through on the rule this
1865
+ // table's test states: the AST gate accepting a spelling is not the driver
1866
+ // compiling it into a WHERE clause.
1867
+ icontains: "icontains",
1476
1868
  not_contains: "notcontains",
1477
1869
  notcontains: "notcontains",
1478
1870
  starts_with: "startswith",
@@ -1494,14 +1886,21 @@ var FILTER_OPERATOR_ALIASES = {
1494
1886
  before: "<",
1495
1887
  after: ">"
1496
1888
  };
1497
- function normalizeFilterOperator2(op) {
1889
+ function toAstFilterOperator(op) {
1498
1890
  if (typeof op !== "string") return null;
1499
1891
  const lower = op.toLowerCase();
1500
1892
  return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
1501
1893
  }
1894
+ function describeFilterEntry(entry) {
1895
+ try {
1896
+ return JSON.stringify(entry, (_key, value) => typeof value === "bigint" ? `${value}n` : value) ?? String(entry);
1897
+ } catch {
1898
+ return String(entry);
1899
+ }
1900
+ }
1502
1901
  var MalformedFilterError = class extends Error {
1503
1902
  constructor(entry, index) {
1504
- const shown = JSON.stringify(entry) ?? String(entry);
1903
+ const shown = describeFilterEntry(entry);
1505
1904
  super(
1506
1905
  `Filter entry ${index} is not a usable filter rule (${shown}). Expected { field, operator, value } with a non-empty field.`
1507
1906
  );
@@ -1519,12 +1918,104 @@ function isMalformedFilterError(error) {
1519
1918
  const e = error;
1520
1919
  return e.code === "INVALID_FILTER" || e.name === "MalformedFilterError";
1521
1920
  }
1522
- function objectFilterEntryToAST(entry) {
1921
+ var UnloweredAggregateWhereError = class extends Error {
1922
+ constructor(where, resource) {
1923
+ const shown = JSON.stringify(where) ?? String(where);
1924
+ super(
1925
+ `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.`
1926
+ );
1927
+ __publicField(this, "code", "INVALID_FILTER");
1928
+ __publicField(this, "httpStatus", 400);
1929
+ /** The value as received, so a caller can log what its producer actually built. */
1930
+ __publicField(this, "where");
1931
+ /** The object `aggregate()` was called for. */
1932
+ __publicField(this, "resource");
1933
+ this.name = "UnloweredAggregateWhereError";
1934
+ this.where = where;
1935
+ this.resource = resource;
1936
+ }
1937
+ };
1938
+ function assertSpecShapeWhereIsFilterAst(where, resource) {
1939
+ if (!Array.isArray(where)) return;
1940
+ if (where.length === 0) return;
1941
+ if (isFilterAST(where)) return;
1942
+ throw new UnloweredAggregateWhereError(where, resource);
1943
+ }
1944
+ var ANALYTICS_ONLY_AGGREGATE_KEYS = ["filter", "field", "function"];
1945
+ var ANALYTICS_ONLY_KEY_ADVICE = {
1946
+ 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.",
1947
+ 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' }].",
1948
+ function: "`function` is the other half of that measure pair and belongs inside an `aggregations` node, not beside `groupBy`."
1949
+ };
1950
+ function specShapeSelectorReasons(params) {
1951
+ const reasons = [];
1952
+ if (Array.isArray(params?.groupBy)) reasons.push("`groupBy` is an array");
1953
+ if (Array.isArray(params?.aggregations)) reasons.push("`aggregations` is an array");
1954
+ if (params?.where !== void 0) reasons.push("a `where` key is present");
1955
+ return reasons;
1956
+ }
1957
+ var AnalyticsKeysOnSpecShapeError = class extends Error {
1958
+ constructor(keys, params, resource) {
1959
+ const received = {};
1960
+ for (const key of keys) received[key] = params?.[key];
1961
+ const named = keys.map((k) => `\`${k}\``).join(", ");
1962
+ const shown = keys.map((k) => `${k}=${JSON.stringify(params?.[k]) ?? String(params?.[k])}`).join(", ");
1963
+ const why = specShapeSelectorReasons(params).join(" and ");
1964
+ 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." : "";
1965
+ super(
1966
+ `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."
1967
+ );
1968
+ __publicField(this, "code", "INVALID_FILTER");
1969
+ __publicField(this, "httpStatus", 400);
1970
+ /** The offending keys, in the order {@link ANALYTICS_ONLY_AGGREGATE_KEYS} lists them. */
1971
+ __publicField(this, "keys");
1972
+ /** What each offending key carried, so a producer is identifiable from a log. */
1973
+ __publicField(this, "received");
1974
+ /** The object `aggregate()` was called for. */
1975
+ __publicField(this, "resource");
1976
+ this.name = "AnalyticsKeysOnSpecShapeError";
1977
+ this.keys = keys;
1978
+ this.received = received;
1979
+ this.resource = resource;
1980
+ }
1981
+ };
1982
+ function assertNoAnalyticsKeysOnSpecShape(params, resource) {
1983
+ const present2 = ANALYTICS_ONLY_AGGREGATE_KEYS.filter((key) => params?.[key] != null);
1984
+ if (present2.length === 0) return;
1985
+ throw new AnalyticsKeysOnSpecShapeError(present2, params, resource);
1986
+ }
1987
+ var UnlowerableAnalyticsFilterError = class extends Error {
1988
+ constructor(filter, resource, detail) {
1989
+ const shown = JSON.stringify(filter) ?? String(filter);
1990
+ super(
1991
+ `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."
1992
+ );
1993
+ __publicField(this, "code", "INVALID_FILTER");
1994
+ __publicField(this, "httpStatus", 400);
1995
+ /** The filter as received, so a caller can log what its producer actually built. */
1996
+ __publicField(this, "filter");
1997
+ /** The object `aggregate()` was called for. */
1998
+ __publicField(this, "resource");
1999
+ this.name = "UnlowerableAnalyticsFilterError";
2000
+ this.filter = filter;
2001
+ this.resource = resource;
2002
+ }
2003
+ };
2004
+ function refuseTextComparandEntry(entry, index, field, arrived) {
2005
+ 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).`;
2006
+ const error = new MalformedFilterError(entry, index);
2007
+ error.message = `Filter entry ${index} is refused. The ${textComparandRefusalReason2(field, arrived, entry.value)}. ${tail}`;
2008
+ throw error;
2009
+ }
2010
+ function objectFilterEntryToAST(entry, index) {
1523
2011
  if (!entry || typeof entry !== "object") return null;
1524
2012
  const field = entry.field;
1525
2013
  const rawOp = entry.operator ?? entry.op ?? "=";
1526
- const op = normalizeFilterOperator2(rawOp);
2014
+ const op = toAstFilterOperator(rawOp);
1527
2015
  if (!field || !op) return null;
2016
+ if (op === "icontains" && isRefusedTextComparand2(entry.value)) {
2017
+ refuseTextComparandEntry(entry, index, String(field), rawOp);
2018
+ }
1528
2019
  return [String(field), op, entry.value];
1529
2020
  }
1530
2021
  function isObjectFilterEntryForm(filter) {
@@ -1533,8 +2024,8 @@ function isObjectFilterEntryForm(filter) {
1533
2024
  }
1534
2025
  function objectFilterEntriesToAST(entries) {
1535
2026
  const nodes = entries.map((entry, i) => {
1536
- if (Array.isArray(entry)) return translateFilterChild(entry);
1537
- const tuple = objectFilterEntryToAST(entry);
2027
+ if (Array.isArray(entry)) return translateFilterChild(entry, i);
2028
+ const tuple = objectFilterEntryToAST(entry, i);
1538
2029
  if (!tuple) throw new MalformedFilterError(entry, i);
1539
2030
  return tuple;
1540
2031
  });
@@ -1545,15 +2036,17 @@ function translateFilterArray(filter) {
1545
2036
  if (isObjectFilterEntryForm(filter)) return objectFilterEntriesToAST(filter);
1546
2037
  const head = filter[0];
1547
2038
  if (typeof head === "string" && LOGICAL_AST_HEADS.has(head.toLowerCase())) {
1548
- return [head, ...filter.slice(1).map(translateFilterChild)];
2039
+ return [head, ...filter.slice(1).map((child, i) => translateFilterChild(child, i + 1))];
2040
+ }
2041
+ if (filter.every((child) => Array.isArray(child))) {
2042
+ return filter.map((child, i) => translateFilterChild(child, i));
1549
2043
  }
1550
- if (filter.every((child) => Array.isArray(child))) return filter.map(translateFilterChild);
1551
2044
  return filter;
1552
2045
  }
1553
- function translateFilterChild(child) {
2046
+ function translateFilterChild(child, index) {
1554
2047
  if (Array.isArray(child)) return child.length > 0 ? translateFilterArray(child) : child;
1555
2048
  if (child && typeof child === "object" && child.field !== void 0) {
1556
- const tuple = objectFilterEntryToAST(child);
2049
+ const tuple = objectFilterEntryToAST(child, index);
1557
2050
  if (tuple) return tuple;
1558
2051
  }
1559
2052
  return child;
@@ -1570,6 +2063,30 @@ function translateFilterToAST(filter) {
1570
2063
  }
1571
2064
  return void 0;
1572
2065
  }
2066
+ function lowerAnalyticsFilterForWire(filter, resource) {
2067
+ if (!Array.isArray(filter)) return filter;
2068
+ if (filter.length === 0) return void 0;
2069
+ const ast = translateFilterArray(filter);
2070
+ if (!isFilterAST(ast)) throw new UnlowerableAnalyticsFilterError(filter, resource);
2071
+ let lowered;
2072
+ try {
2073
+ lowered = parseFilterAST(ast);
2074
+ } catch (e) {
2075
+ throw new UnlowerableAnalyticsFilterError(
2076
+ filter,
2077
+ resource,
2078
+ e instanceof Error ? e.message : String(e)
2079
+ );
2080
+ }
2081
+ if (lowered === void 0) {
2082
+ throw new UnlowerableAnalyticsFilterError(
2083
+ filter,
2084
+ resource,
2085
+ "`parseFilterAST` answered `undefined` for a non-empty filter"
2086
+ );
2087
+ }
2088
+ return lowered;
2089
+ }
1573
2090
  function serializeOrderBy(orderby) {
1574
2091
  if (orderby === void 0 || orderby === null) return void 0;
1575
2092
  if (typeof orderby === "string") {
@@ -1632,22 +2149,134 @@ var APP_PERMISSION_DENIED_CODE = "PERMISSION_DENIED";
1632
2149
  function isAppPermissionDeniedError(error) {
1633
2150
  return errorCodeIs(error, APP_PERMISSION_DENIED_CODE);
1634
2151
  }
2152
+ var META_ITEM_ABSENT_CODE = "RESOURCE_NOT_FOUND";
2153
+ function isMetaItemAbsentError(error) {
2154
+ return errorCodeIs(error, META_ITEM_ABSENT_CODE);
2155
+ }
2156
+ function metaEnvelopeCarriesItem(envelope) {
2157
+ if (envelope == null || typeof envelope !== "object") return false;
2158
+ return envelope.item != null;
2159
+ }
2160
+ var VIEW_CONFIG_CAPABILITY = "manage_metadata";
2161
+ var ViewConfigPermissionDeniedError = class extends Error {
2162
+ constructor(objectName, viewId) {
2163
+ super(
2164
+ `View configuration is shared: changing "${viewId}" changes it for everyone who uses this view. That requires the "${VIEW_CONFIG_CAPABILITY}" capability, which this session does not hold.`
2165
+ );
2166
+ __publicField(this, "code", "VIEW_CONFIG_PERMISSION_DENIED");
2167
+ /** The capability that was required and not held. */
2168
+ __publicField(this, "capability", VIEW_CONFIG_CAPABILITY);
2169
+ /** The object whose view configuration the refused write targeted. */
2170
+ __publicField(this, "objectName");
2171
+ /** The view id the refused write targeted. */
2172
+ __publicField(this, "viewId");
2173
+ this.name = "ViewConfigPermissionDeniedError";
2174
+ this.objectName = objectName;
2175
+ this.viewId = viewId;
2176
+ }
2177
+ };
2178
+ function isViewConfigPermissionDeniedError(error) {
2179
+ if (!error || typeof error !== "object") return false;
2180
+ const e = error;
2181
+ return e.code === "VIEW_CONFIG_PERMISSION_DENIED" || e.name === "ViewConfigPermissionDeniedError";
2182
+ }
1635
2183
  var AnalyticsNotInstalledError = class extends Error {
1636
- constructor(surface, detail) {
2184
+ constructor(surface, detail, serverCode) {
1637
2185
  super(
1638
2186
  `Analytics capability is not installed on this deployment \u2014 ${surface} is unavailable. Install @objectstack/service-analytics and mount AnalyticsServicePlugin to enable it.` + (detail ? ` (server said: ${detail})` : "")
1639
2187
  );
1640
2188
  __publicField(this, "code", "ANALYTICS_NOT_INSTALLED");
1641
2189
  /** The surface that was unavailable, for the message a host renders. */
1642
2190
  __publicField(this, "surface");
2191
+ /**
2192
+ * The server's own ADR-0112 code — the field this branch was chosen BY, when
2193
+ * a producer named one (`NOT_IMPLEMENTED` from the mounted route with no
2194
+ * service behind it, `ROUTE_NOT_FOUND` from the dispatcher when the route is
2195
+ * not mounted at all). Absent for the no-code residual, where a bare
2196
+ * transport 404/501 is all there was to read.
2197
+ *
2198
+ * Additive (objectui#5663): carried so a reader can audit that the headline
2199
+ * and the quoted `detail` below came off the same answer.
2200
+ */
2201
+ __publicField(this, "serverCode");
1643
2202
  this.name = "AnalyticsNotInstalledError";
1644
2203
  this.surface = surface;
2204
+ this.serverCode = serverCode;
1645
2205
  }
1646
2206
  };
1647
2207
  function isAnalyticsNotInstalledError(error) {
1648
2208
  if (!error || typeof error !== "object") return false;
1649
2209
  return error.code === "ANALYTICS_NOT_INSTALLED";
1650
2210
  }
2211
+ var AnalyticsDatasetNotFoundError = class extends Error {
2212
+ constructor(opts) {
2213
+ const subject = opts.datasetName ? `Dataset "${opts.datasetName}"` : "The requested dataset";
2214
+ super(
2215
+ `${subject} is not defined in this environment \u2014 the app that defines it is missing or out of date. Analytics itself is installed and answering, so the fix is to upgrade the installed app, not to install a server plugin.` + (opts.serverMessage ? ` (server said: ${opts.serverMessage})` : "")
2216
+ );
2217
+ __publicField(this, "code", "ANALYTICS_DATASET_NOT_FOUND");
2218
+ /**
2219
+ * The dataset this query asked for, taken from the REQUEST rather than parsed
2220
+ * back out of the server's prose: the request is what we know for certain,
2221
+ * and re-reading the name out of a message would make the headline depend on
2222
+ * that message's WORDING — the coupling framework#5367 spent a whole issue
2223
+ * removing from the producing route.
2224
+ */
2225
+ __publicField(this, "datasetName");
2226
+ /** The server's own ADR-0112 code — the field this branch was chosen BY. */
2227
+ __publicField(this, "serverCode");
2228
+ /** The server's own message, quoted verbatim in the parenthetical. */
2229
+ __publicField(this, "serverMessage");
2230
+ this.name = "AnalyticsDatasetNotFoundError";
2231
+ this.datasetName = opts.datasetName;
2232
+ this.serverCode = opts.serverCode;
2233
+ this.serverMessage = opts.serverMessage;
2234
+ }
2235
+ };
2236
+ var AnalyticsUnauthenticatedError = class extends Error {
2237
+ constructor(opts = {}) {
2238
+ super(
2239
+ "Analytics query refused: this session is not authenticated. Sign in again and retry \u2014 the request was refused before it ran, so it says nothing about whether the analytics capability is installed." + (opts.serverMessage ? ` (server said: ${opts.serverMessage})` : "")
2240
+ );
2241
+ __publicField(this, "code", "ANALYTICS_UNAUTHENTICATED");
2242
+ /** The server's own ADR-0112 code — the field this branch was chosen BY. */
2243
+ __publicField(this, "serverCode");
2244
+ /** The server's own message, quoted verbatim in the parenthetical. */
2245
+ __publicField(this, "serverMessage");
2246
+ this.name = "AnalyticsUnauthenticatedError";
2247
+ this.serverCode = opts.serverCode;
2248
+ this.serverMessage = opts.serverMessage;
2249
+ }
2250
+ };
2251
+ var AnalyticsForbiddenError = class extends Error {
2252
+ constructor(opts = {}) {
2253
+ super(
2254
+ `Analytics query refused: this user may not read the data behind ${opts.datasetName ? `dataset "${opts.datasetName}"` : "this dataset"}.` + (opts.serverMessage ? ` (server said: ${opts.serverMessage})` : "")
2255
+ );
2256
+ __publicField(this, "code", "PERMISSION_DENIED");
2257
+ __publicField(this, "httpStatus", 403);
2258
+ /** The server's own ADR-0112 code — the field this branch was chosen BY. */
2259
+ __publicField(this, "serverCode");
2260
+ /** The server's own message, verbatim (diagnostics only). */
2261
+ __publicField(this, "serverMessage");
2262
+ /** The dataset the refused query asked for, when it named one. */
2263
+ __publicField(this, "datasetName");
2264
+ this.name = "AnalyticsForbiddenError";
2265
+ this.serverCode = opts.serverCode;
2266
+ this.serverMessage = opts.serverMessage;
2267
+ this.datasetName = opts.datasetName;
2268
+ }
2269
+ };
2270
+ function readAnalyticsErrorEnvelope(body) {
2271
+ if (!body || typeof body !== "object") return {};
2272
+ const flat = body;
2273
+ const wrapped = flat.error && typeof flat.error === "object" ? flat.error : void 0;
2274
+ const source = wrapped ?? flat;
2275
+ const code = typeof source.code === "string" && source.code.length > 0 ? source.code : void 0;
2276
+ const message = typeof source.message === "string" && source.message.length > 0 ? source.message : typeof flat.error === "string" && flat.error.length > 0 ? flat.error : void 0;
2277
+ return { code, message };
2278
+ }
2279
+ var ANALYTICS_ABSENT_STATUSES = [404, 501];
1651
2280
  var AnalyticsQueryRejectedError = class extends Error {
1652
2281
  constructor(detail, serverCode) {
1653
2282
  super(
@@ -1660,15 +2289,48 @@ var AnalyticsQueryRejectedError = class extends Error {
1660
2289
  this.serverCode = serverCode;
1661
2290
  }
1662
2291
  };
2292
+ var AnalyticsResultShapeError = class extends Error {
2293
+ constructor(resource, result) {
2294
+ const value = result;
2295
+ const envelope = Array.isArray(value?.data?.rows);
2296
+ const received = result === null ? "null" : Array.isArray(result) ? "an array" : typeof result !== "object" ? typeof result : `an object with keys [${Object.keys(result).join(", ")}]`;
2297
+ super(
2298
+ 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).`
2299
+ );
2300
+ __publicField(this, "code", "ANALYTICS_RESULT_SHAPE_INVALID");
2301
+ /** The object `aggregate()` was called for. */
2302
+ __publicField(this, "resource");
2303
+ /**
2304
+ * True when the value is the envelope the client stopped handing back at
2305
+ * 17.3.0 — `{ success, data: { rows } }`, or its `{ data: { rows } }` core.
2306
+ */
2307
+ __publicField(this, "envelope");
2308
+ /** What the value was, so the producer is identifiable from a log. */
2309
+ __publicField(this, "received");
2310
+ this.name = "AnalyticsResultShapeError";
2311
+ this.resource = resource;
2312
+ this.envelope = envelope;
2313
+ this.received = received;
2314
+ }
2315
+ };
1663
2316
  function classifyAnalyticsFailure(error) {
1664
2317
  const err = error ?? {};
1665
- const code = typeof err.code === "string" ? err.code : void 0;
2318
+ const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
1666
2319
  const message = typeof err.message === "string" ? err.message : void 0;
1667
2320
  const status = typeof err.httpStatus === "number" ? err.httpStatus : typeof err.status === "number" ? err.status : typeof err.statusCode === "number" ? err.statusCode : void 0;
1668
- if (code === "VALIDATION_FAILED" || status === 400) return { kind: "rejected", code, message };
1669
- if (status === 404 || status === 501 || code === "NOT_IMPLEMENTED" || code === "ROUTE_NOT_FOUND") {
2321
+ if (errorCodeIsAnyOf({ code }, ["NOT_IMPLEMENTED", "ROUTE_NOT_FOUND"])) {
1670
2322
  return { kind: "not-installed", code, message };
1671
2323
  }
2324
+ if (errorCodeIs({ code }, "VALIDATION_FAILED")) return { kind: "rejected", code, message };
2325
+ if (errorCodeIs({ code }, "UNAUTHENTICATED")) return { kind: "unauthenticated", code, message };
2326
+ if (errorCodeIs({ code }, "CUBE_NOT_FOUND")) return { kind: "cube-not-found", code, message };
2327
+ if (status === 400) return { kind: "rejected", code, message };
2328
+ if (code === void 0) {
2329
+ if (status !== void 0 && ANALYTICS_ABSENT_STATUSES.includes(status)) {
2330
+ return { kind: "not-installed", code, message };
2331
+ }
2332
+ if (status === 401) return { kind: "unauthenticated", code, message };
2333
+ }
1672
2334
  return { kind: "unknown", code, message };
1673
2335
  }
1674
2336
  var ConcurrentUpdateError = class extends Error {
@@ -1678,9 +2340,22 @@ var ConcurrentUpdateError = class extends Error {
1678
2340
  __publicField(this, "httpStatus", 409);
1679
2341
  __publicField(this, "currentVersion");
1680
2342
  __publicField(this, "currentRecord");
2343
+ /**
2344
+ * The refusal text the PRODUCER marked as addressed to the end user
2345
+ * (`ApiErrorSchema.userMessage`, objectstack `79c46da90`), or `null` when the
2346
+ * refusal carried no marking.
2347
+ *
2348
+ * Declared on the class because this error has no `details` bag: the shared
2349
+ * reader (`declaredUserMessage` in `@object-ui/react`) looks at the
2350
+ * top-level field first, so parking it here is what makes the marking
2351
+ * readable at the surface. The contract is status-agnostic — a 409 carries
2352
+ * it exactly like a 400 or a 403 does.
2353
+ */
2354
+ __publicField(this, "userMessage");
1681
2355
  this.name = "ConcurrentUpdateError";
1682
2356
  this.currentVersion = opts.currentVersion;
1683
2357
  this.currentRecord = opts.currentRecord;
2358
+ this.userMessage = opts.userMessage ?? null;
1684
2359
  }
1685
2360
  };
1686
2361
  function isConcurrentUpdateError(error) {
@@ -1688,10 +2363,18 @@ function isConcurrentUpdateError(error) {
1688
2363
  const e = error;
1689
2364
  return e.code === "CONCURRENT_UPDATE" || e.name === "ConcurrentUpdateError";
1690
2365
  }
2366
+ function liftUserMessage(e, details) {
2367
+ if (typeof e.userMessage === "string" && e.userMessage.trim()) return e.userMessage;
2368
+ if (typeof details.userMessage === "string" && details.userMessage.trim()) {
2369
+ return details.userMessage;
2370
+ }
2371
+ return null;
2372
+ }
1691
2373
  function normaliseClientError(error) {
1692
2374
  if (!error || typeof error !== "object") return error;
1693
2375
  const e = error;
1694
2376
  const details = e.details ?? {};
2377
+ const userMessage = liftUserMessage(e, details);
1695
2378
  if (e.code === "VALIDATION_FAILED" || e.name === "ValidationError") {
1696
2379
  const rawFields = Array.isArray(details.fields) ? details.fields : Array.isArray(e.fields) ? e.fields : [];
1697
2380
  const validationErrors = rawFields.map((f) => {
@@ -1705,15 +2388,21 @@ function normaliseClientError(error) {
1705
2388
  typeof e.message === "string" ? e.message : "Validation failed",
1706
2389
  validationErrors[0]?.field,
1707
2390
  validationErrors,
1708
- { fields: rawFields }
2391
+ // The marking rides the details bag exactly the way `fields` already
2392
+ // does, landing on `err.details.userMessage` — the second of the two
2393
+ // places the shared reader looks. Purely additive: `field`,
2394
+ // `validationErrors` and `fields` are untouched, so the per-field
2395
+ // marking a form already draws keeps working. Omitted entirely when
2396
+ // unmarked, so an unmarked refusal carries no empty key.
2397
+ userMessage === null ? { fields: rawFields } : { fields: rawFields, userMessage }
1709
2398
  );
1710
2399
  }
1711
- if (e.code !== "CONCURRENT_UPDATE" && e.httpStatus !== 409) return error;
1712
2400
  if (e.code !== "CONCURRENT_UPDATE") return error;
1713
2401
  return new ConcurrentUpdateError({
1714
2402
  currentVersion: typeof details.currentVersion === "string" ? details.currentVersion : null,
1715
2403
  currentRecord: details.currentRecord ?? null,
1716
- message: typeof e.message === "string" ? e.message : void 0
2404
+ message: typeof e.message === "string" ? e.message : void 0,
2405
+ userMessage
1717
2406
  });
1718
2407
  }
1719
2408
  function formatHttpFailureMessage(message, meta) {
@@ -1741,8 +2430,14 @@ function createQuietHttpLogger() {
1741
2430
  return false;
1742
2431
  };
1743
2432
  const logger = {
1744
- debug: (message, meta) => console.debug(message, meta ?? ""),
1745
- info: (message, meta) => console.info(message, meta ?? ""),
2433
+ debug: (message, meta) => (
2434
+ // eslint-disable-next-line no-console -- objectui#4029, see comment above
2435
+ console.debug(message, meta ?? "")
2436
+ ),
2437
+ info: (message, meta) => (
2438
+ // eslint-disable-next-line no-console -- objectui#4029, see comment above
2439
+ console.info(message, meta ?? "")
2440
+ ),
1746
2441
  warn: (message, meta) => console.warn(message, meta ?? ""),
1747
2442
  error: (message, error, meta) => {
1748
2443
  if (isExpected404(meta)) {
@@ -1755,12 +2450,87 @@ function createQuietHttpLogger() {
1755
2450
  console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? "", meta ?? "");
1756
2451
  },
1757
2452
  fatal: (message, error, meta) => console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? "", meta ?? ""),
2453
+ // eslint-disable-next-line no-console -- objectui#4029, see comment above
1758
2454
  log: (message, ...args) => console.log(message, ...args),
1759
2455
  child: () => logger,
1760
2456
  withTrace: () => logger
1761
2457
  };
1762
2458
  return logger;
1763
2459
  }
2460
+ var RECOGNIZED_DROP_REASONS = new Set(
2461
+ DroppedFieldsEventSchema.shape.reason.options
2462
+ );
2463
+ var UNRECOGNIZED_DROP_REASON = "objectui:unrecognized-drop-reason";
2464
+ function isRecognizedDropReason(reason) {
2465
+ return RECOGNIZED_DROP_REASONS.has(reason);
2466
+ }
2467
+ function isWireDroppedFieldsEntry(e) {
2468
+ return !!e && typeof e === "object" && Array.isArray(e.fields) && e.fields.some((f) => typeof f === "string");
2469
+ }
2470
+ function asDroppedFieldsNotice(entry, fallbackObject) {
2471
+ const fields = entry.fields.filter((f) => typeof f === "string");
2472
+ const object = typeof entry.object === "string" ? entry.object : fallbackObject;
2473
+ if (isRecognizedDropReason(entry.reason)) {
2474
+ return { ...entry, object, fields, reason: entry.reason };
2475
+ }
2476
+ return {
2477
+ ...entry,
2478
+ object,
2479
+ fields,
2480
+ reason: UNRECOGNIZED_DROP_REASON,
2481
+ unrecognizedReason: entry.reason
2482
+ };
2483
+ }
2484
+ var UNATTRIBUTED_STRIP_OBJECT = "";
2485
+ var META_ROUTE_ABSENT_CODES = ["ROUTE_NOT_FOUND", "NOT_IMPLEMENTED"];
2486
+ var IMPORT_MAPPINGS_UNKNOWN_KIND_CODE = "INVALID_REQUEST";
2487
+ var IMPORT_MAPPINGS_UNKNOWN_KIND_STATUS = 400;
2488
+ var META_ABSENT_STATUSES = [404, 501];
2489
+ var META_REFUSAL_CODES = ["UNAUTHENTICATED", "PERMISSION_DENIED"];
2490
+ var META_REFUSAL_STATUSES = [401, 403, 405];
2491
+ function classifyImportMappingsFailure(error) {
2492
+ const err = error ?? {};
2493
+ const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
2494
+ const message = typeof err.message === "string" ? err.message : void 0;
2495
+ const status = typeof err.httpStatus === "number" ? err.httpStatus : typeof err.status === "number" ? err.status : typeof err.statusCode === "number" ? err.statusCode : void 0;
2496
+ const found = { code, status, message };
2497
+ if (errorCodeIsAnyOf({ code }, META_ROUTE_ABSENT_CODES)) {
2498
+ return { kind: "not-served", ...found };
2499
+ }
2500
+ if (errorCodeIs({ code }, IMPORT_MAPPINGS_UNKNOWN_KIND_CODE) && status === IMPORT_MAPPINGS_UNKNOWN_KIND_STATUS) {
2501
+ return { kind: "not-served", ...found };
2502
+ }
2503
+ if (errorCodeIsAnyOf({ code }, META_REFUSAL_CODES)) {
2504
+ return { kind: "refused", ...found };
2505
+ }
2506
+ if (status !== void 0 && META_REFUSAL_STATUSES.includes(status)) {
2507
+ return { kind: "refused", ...found };
2508
+ }
2509
+ if (code === void 0 && status !== void 0 && META_ABSENT_STATUSES.includes(status)) {
2510
+ return { kind: "not-served", ...found };
2511
+ }
2512
+ return { kind: "unreadable", ...found };
2513
+ }
2514
+ function classifyViewsFailure(error) {
2515
+ const err = error ?? {};
2516
+ const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
2517
+ const message = typeof err.message === "string" ? err.message : void 0;
2518
+ const status = typeof err.httpStatus === "number" ? err.httpStatus : typeof err.status === "number" ? err.status : typeof err.statusCode === "number" ? err.statusCode : void 0;
2519
+ const found = { code, status, message };
2520
+ if (errorCodeIsAnyOf({ code }, META_ROUTE_ABSENT_CODES)) {
2521
+ return { kind: "not-served", ...found };
2522
+ }
2523
+ if (errorCodeIsAnyOf({ code }, META_REFUSAL_CODES)) {
2524
+ return { kind: "refused", ...found };
2525
+ }
2526
+ if (status !== void 0 && META_REFUSAL_STATUSES.includes(status)) {
2527
+ return { kind: "refused", ...found };
2528
+ }
2529
+ if (code === void 0 && status !== void 0 && META_ABSENT_STATUSES.includes(status)) {
2530
+ return { kind: "not-served", ...found };
2531
+ }
2532
+ return { kind: "unreadable", ...found };
2533
+ }
1764
2534
  function stableStringify(value) {
1765
2535
  if (value === null || typeof value !== "object") return JSON.stringify(value);
1766
2536
  if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
@@ -1793,15 +2563,48 @@ function withoutNoOpDrops(droppedFields, sent, stored) {
1793
2563
  }
1794
2564
  return out;
1795
2565
  }
2566
+ var VIEW_ITEM_ROUND_TRIP_KEYS = Object.keys(VIEW_CONSOLE_ROUND_TRIP_KEYS).filter((key) => VIEW_CONSOLE_ROUND_TRIP_KEYS[key].includes("viewItem"));
1796
2567
  function viewItemObjectName(item) {
1797
2568
  const spec = item?.list ?? item;
1798
- return spec?.data?.object ?? spec?.object ?? spec?.objectName;
2569
+ return spec?.data?.object ?? spec?.object;
2570
+ }
2571
+ var VIEW_OVERLAY_MARKER = "_isOverride";
2572
+ function isPersonalizationOverlayRow(item, spec) {
2573
+ return item?.[VIEW_OVERLAY_MARKER] === true || spec?.[VIEW_OVERLAY_MARKER] === true;
2574
+ }
2575
+ var VIEW_OVERLAY_OWNED_KEYS = Object.freeze([
2576
+ "rowHeight",
2577
+ "sort",
2578
+ "hiddenFields",
2579
+ "columnState",
2580
+ "inlineEdit"
2581
+ ]);
2582
+ var VIEW_OVERLAY_IDENTITY_KEYS = Object.freeze([
2583
+ "name",
2584
+ "object",
2585
+ "viewKind",
2586
+ VIEW_OVERLAY_MARKER
2587
+ ]);
2588
+ function narrowPersonalizationOverlay(row) {
2589
+ if (!row || typeof row !== "object" || Array.isArray(row)) return row;
2590
+ const item = row;
2591
+ const spec = item.list ?? item;
2592
+ if (!isPersonalizationOverlayRow(item, spec)) return row;
2593
+ const narrowed = {};
2594
+ for (const key of VIEW_OVERLAY_IDENTITY_KEYS) {
2595
+ if (item[key] !== void 0) narrowed[key] = item[key];
2596
+ }
2597
+ for (const key of VIEW_OVERLAY_OWNED_KEYS) {
2598
+ if (item[key] !== void 0) narrowed[key] = item[key];
2599
+ }
2600
+ return narrowed;
1799
2601
  }
1800
2602
  function unwrapViewDraft(resp) {
1801
2603
  if (!resp || typeof resp !== "object") return null;
1802
2604
  const env = resp;
1803
- const body = "item" in env ? env.item : env;
1804
- if (!body || typeof body !== "object") return null;
2605
+ const raw = "item" in env ? env.item : env;
2606
+ if (!raw || typeof raw !== "object") return null;
2607
+ const body = stripReadDecorations2(raw);
1805
2608
  const spec = body.list ?? body;
1806
2609
  if (!spec || typeof spec !== "object") return null;
1807
2610
  return Object.keys(spec).length > 0 ? spec : null;
@@ -1857,10 +2660,10 @@ var ObjectStackAdapter = class {
1857
2660
  // one adapter that has to cope with a backend lacking server atomicity (#2679).
1858
2661
  __publicField(this, "batchUnsupported", false);
1859
2662
  // The server's declared cross-object atomic-batch capability, read from
1860
- // discovery at connect() (framework #3298 / objectui #2693). `true` → the
2663
+ // discovery at connect() (objectstack-ai/objectstack#3298 / objectui #2693). `true` → the
1861
2664
  // backend GUARANTEES an atomic `/batch`, so batchTransaction trusts it and
1862
2665
  // never degrades to the non-atomic emulation (any failure surfaces as a real
1863
- // error). `false` or `undefined` (capability absent → backend predates #3298)
2666
+ // error). `false` or `undefined` (capability absent → backend predates objectstack-ai/objectstack#3298)
1864
2667
  // → keep the legacy runtime-probe + emulation fallback so a save is still
1865
2668
  // possible; dropping it there would turn "saves, less safe" into "no save
1866
2669
  // path" on older backends (#2679 compatibility constraint).
@@ -1871,7 +2674,7 @@ var ObjectStackAdapter = class {
1871
2674
  // inline-edit "Save All" writes without a manual reload.
1872
2675
  __publicField(this, "mutationListeners", /* @__PURE__ */ new Set());
1873
2676
  // Subscribers registered via onWriteWarning(). Emitted after a create/update
1874
- // whose response carried `droppedFields` (framework #3431/#3455) so the app
2677
+ // whose response carried `droppedFields` (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455) so the app
1875
2678
  // shell can surface a toast instead of the strip passing silently.
1876
2679
  __publicField(this, "writeWarningListeners", /* @__PURE__ */ new Set());
1877
2680
  // Subscribers registered via onSaveAdvisory(). Emitted after a metadata save
@@ -1880,6 +2683,18 @@ var ObjectStackAdapter = class {
1880
2683
  // above in every respect except which door produced the event: that one is
1881
2684
  // record CRUD, this one is the metadata save door.
1882
2685
  __publicField(this, "saveAdvisoryListeners", /* @__PURE__ */ new Set());
2686
+ // Subscribers registered via onMetadataReadWarning(). Emitted when a metadata
2687
+ // READ failed in a way that is NOT the supported "this deployment does not
2688
+ // serve that kind" shape, and was degraded to an empty result anyway
2689
+ // (objectui#7741). Third sibling of the two sets above: same seam shape, and
2690
+ // the opposite direction — those two describe a write that succeeded, this
2691
+ // one a read that did not happen.
2692
+ __publicField(this, "metadataReadWarningListeners", /* @__PURE__ */ new Set());
2693
+ // [ADR-0066] The session's REPORTED system capabilities, pushed in by the
2694
+ // host (see `setSystemCapabilities`). `undefined` means NO answer was ever
2695
+ // reported — which is NOT the same as a reported-empty grant, and the two
2696
+ // are treated differently by `maySetViewConfig` below.
2697
+ __publicField(this, "systemCapabilities");
1883
2698
  this.client = new ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
1884
2699
  this.installSaveAdvisoryInterceptor();
1885
2700
  this.metadataCache = new MetadataCache(config.cache);
@@ -1888,6 +2703,7 @@ var ObjectStackAdapter = class {
1888
2703
  this.reconnectDelay = config.reconnectDelay ?? 1e3;
1889
2704
  this.baseUrl = config.baseUrl;
1890
2705
  this.token = config.token;
2706
+ this.systemCapabilities = config.systemCapabilities;
1891
2707
  this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
1892
2708
  }
1893
2709
  /**
@@ -2061,7 +2877,7 @@ var ObjectStackAdapter = class {
2061
2877
  * — a per-object metadata-driven search (ADR-0061) — this consults the search
2062
2878
  * index and ranks hits across objects, so it surfaces records the per-object
2063
2879
  * fanout misses. Global affordances (⌘K command palette, search page) prefer
2064
- * this path (framework #3371).
2880
+ * this path (objectstack-ai/objectstack#3371).
2065
2881
  *
2066
2882
  * Returns `{ query, hits }`. A backend without the search plugin installed
2067
2883
  * answers `404`; we treat that as "no global search here" and return an empty
@@ -2094,10 +2910,10 @@ var ObjectStackAdapter = class {
2094
2910
  if (!res.ok) {
2095
2911
  if (res.status === 404) return { query: trimmed, hits: [] };
2096
2912
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
2097
- const err = new Error(
2098
- errorBody?.error?.message || errorBody?.message || res.statusText
2099
- );
2913
+ const envelope = readErrorEnvelope(errorBody);
2914
+ const err = new Error(envelope.message ?? res.statusText);
2100
2915
  err.status = res.status;
2916
+ err.code = envelope.code;
2101
2917
  throw err;
2102
2918
  }
2103
2919
  const body = await res.json();
@@ -2200,24 +3016,28 @@ var ObjectStackAdapter = class {
2200
3016
  }
2201
3017
  }
2202
3018
  /**
2203
- * Read `droppedFields` off a create/update response (framework #3431/#3455)
3019
+ * Read `droppedFields` off a create/update response (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455)
2204
3020
  * and, when present, notify write-warning subscribers. Tolerant of a client
2205
3021
  * whose response type predates `droppedFields`: the field is read structurally
2206
3022
  * and validated, so an older client (or a backend that never drops) is a no-op.
3023
+ *
3024
+ * SHAPE decides whether an entry is an event at all (it must name at least one
3025
+ * field); `object`, `fields` and `reason` are then PARSED — an unrecognized
3026
+ * reason routed to the skew arm, a non-string field element refused, a missing
3027
+ * `object` healed from `resource` — never asserted into the union, and the
3028
+ * entry itself never dropped for them (objectui#4934, `f75810e7c`).
2207
3029
  */
2208
3030
  notifyDroppedFields(operation, resource, result, id, sent) {
2209
3031
  const dropped = result?.droppedFields;
2210
3032
  if (!Array.isArray(dropped) || dropped.length === 0) return;
2211
- const valid = dropped.filter(
2212
- (e) => !!e && typeof e === "object" && Array.isArray(e.fields) && e.fields.length > 0
2213
- );
3033
+ const valid = dropped.filter(isWireDroppedFieldsEntry).map((e) => asDroppedFieldsNotice(e, resource));
2214
3034
  const stored = result?.record;
2215
3035
  const droppedFields = withoutNoOpDrops(valid, sent, stored);
2216
3036
  if (droppedFields.length === 0) return;
2217
3037
  this.emitWriteWarning({ operation, resource, ...id !== void 0 ? { id } : {}, droppedFields });
2218
3038
  }
2219
3039
  /**
2220
- * Same, for the cross-object transactional batch (framework #3794). Its
3040
+ * Same, for the cross-object transactional batch (objectstack-ai/objectstack#3794). Its
2221
3041
  * response hangs the events off a top-level `droppedFields` list, each tagged
2222
3042
  * with the `index` of the operation it came from — `results` entries are bare
2223
3043
  * record echoes with nowhere to hang a per-row list.
@@ -2233,13 +3053,13 @@ var ObjectStackAdapter = class {
2233
3053
  if (!Array.isArray(dropped) || dropped.length === 0) return;
2234
3054
  const results = payload?.results;
2235
3055
  for (const entry of dropped) {
2236
- if (!entry || typeof entry !== "object") continue;
3056
+ if (!isWireDroppedFieldsEntry(entry)) continue;
2237
3057
  const e = entry;
2238
- if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
2239
3058
  const op = typeof e.index === "number" ? operations[e.index] : void 0;
3059
+ const object = typeof e.object === "string" ? e.object : typeof op?.object === "string" ? op.object : UNATTRIBUTED_STRIP_OBJECT;
2240
3060
  const stored = typeof e.index === "number" && Array.isArray(results) ? results[e.index] : void 0;
2241
3061
  const [live] = withoutNoOpDrops(
2242
- [{ object: e.object, fields: e.fields, reason: e.reason }],
3062
+ [asDroppedFieldsNotice({ fields: e.fields, reason: e.reason }, object)],
2243
3063
  op?.data,
2244
3064
  stored
2245
3065
  );
@@ -2247,7 +3067,7 @@ var ObjectStackAdapter = class {
2247
3067
  const operation = (op?.action ?? "create") === "create" ? "create" : "update";
2248
3068
  this.emitWriteWarning({
2249
3069
  operation,
2250
- resource: e.object ?? op?.object ?? "",
3070
+ resource: object,
2251
3071
  ...op?.id !== void 0 && op?.id !== null ? { id: op.id } : {},
2252
3072
  droppedFields: [live]
2253
3073
  });
@@ -2255,7 +3075,7 @@ var ObjectStackAdapter = class {
2255
3075
  }
2256
3076
  /**
2257
3077
  * Subscribe to write-warning events (a create/update dropped caller-supplied
2258
- * fields — #3431/#3455). Returns an unsubscribe function. The app shell uses
3078
+ * fields — objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455). Returns an unsubscribe function. The app shell uses
2259
3079
  * this to toast the user; the write itself already succeeded.
2260
3080
  */
2261
3081
  onWriteWarning(callback) {
@@ -2269,7 +3089,7 @@ var ObjectStackAdapter = class {
2269
3089
  * advisory findings on a save that SUCCEEDED (#4237; backend
2270
3090
  * objectstack#7435). Returns an unsubscribe function.
2271
3091
  *
2272
- * Deliberately the same seam as {@link onWriteWarning} (#3431/#3455), which
3092
+ * Deliberately the same seam as {@link onWriteWarning} (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455), which
2273
3093
  * is what {@link MetadataSaveAdvisoryEvent}'s own declaration already said it
2274
3094
  * was modelled on. It is a SIBLING of that channel rather than a second
2275
3095
  * payload pushed down it: `WriteWarningEvent` is a closed shape whose
@@ -2306,6 +3126,45 @@ var ObjectStackAdapter = class {
2306
3126
  }
2307
3127
  }
2308
3128
  }
3129
+ /**
3130
+ * Subscribe to metadata read-warning events — a metadata READ that could not
3131
+ * be answered and was degraded to an empty result anyway (objectui#7741).
3132
+ * Returns an unsubscribe function. The app shell uses this to toast the user;
3133
+ * the caller has already been handed the empty result.
3134
+ *
3135
+ * Deliberately the same seam as {@link onWriteWarning} and
3136
+ * {@link onSaveAdvisory}, and a SIBLING of them rather than a payload pushed
3137
+ * down either: both of those announce a write that SUCCEEDED, and an event
3138
+ * saying "this read did not happen" carried on one of them would make it lie
3139
+ * about what occurred. What is reused is the seam's SHAPE — one long-lived
3140
+ * instance with a `subscribe -> unsubscribe` registration that
3141
+ * `AdapterProvider` wires once — not its event type.
3142
+ *
3143
+ * ⛔ Nothing here changes what a caller RECEIVES. `listImportMappings` still
3144
+ * answers `[]` on every failure, including the loud ones; this channel is
3145
+ * added ALONGSIDE that return, which is why it is not a change to a surface
3146
+ * published since `@object-ui/data-objectstack@17.1.0`.
3147
+ */
3148
+ onMetadataReadWarning(callback) {
3149
+ this.metadataReadWarningListeners.add(callback);
3150
+ return () => {
3151
+ this.metadataReadWarningListeners.delete(callback);
3152
+ };
3153
+ }
3154
+ /**
3155
+ * Notify all metadata read-warning subscribers. Isolated exactly like
3156
+ * {@link emitWriteWarning}: a throwing listener must neither break the caller
3157
+ * nor starve the others.
3158
+ */
3159
+ emitMetadataReadWarning(event) {
3160
+ for (const listener of this.metadataReadWarningListeners) {
3161
+ try {
3162
+ listener(event);
3163
+ } catch (err) {
3164
+ console.warn("ObjectStackAdapter: metadata read-warning listener error", err);
3165
+ }
3166
+ }
3167
+ }
2309
3168
  /**
2310
3169
  * Install the ONE emitter for the metadata save door (#4237).
2311
3170
  *
@@ -2363,6 +3222,12 @@ var ObjectStackAdapter = class {
2363
3222
  this.emitSaveAdvisory({
2364
3223
  type,
2365
3224
  name,
3225
+ // #5026 — this interceptor wraps `meta.saveItem`, the SAVE door
3226
+ // (`PUT /meta/:type/:name`) and only that one. The SDK's publish
3227
+ // door (`meta.publishItem`) has no caller in this repo, so wiring
3228
+ // it here would be a surface with no consumer; `MetadataClient` is
3229
+ // where the publish door is actually taken.
3230
+ door: "save",
2366
3231
  mode: result?.state === "draft" ? "draft" : "publish",
2367
3232
  advisories
2368
3233
  });
@@ -2410,7 +3275,7 @@ var ObjectStackAdapter = class {
2410
3275
  data,
2411
3276
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
2412
3277
  );
2413
- this.emitMutation({ type: "update", resource, id, record: { ...result.record } });
3278
+ this.emitMutation({ type: "update", resource, id: String(id), record: { ...result.record } });
2414
3279
  this.notifyDroppedFields("update", resource, result, id, data);
2415
3280
  return result.record;
2416
3281
  } catch (err) {
@@ -2433,7 +3298,7 @@ var ObjectStackAdapter = class {
2433
3298
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
2434
3299
  );
2435
3300
  if (result.success) {
2436
- this.emitMutation({ type: "delete", resource, id });
3301
+ this.emitMutation({ type: "delete", resource, id: String(id) });
2437
3302
  }
2438
3303
  return result.success;
2439
3304
  } catch (err) {
@@ -2534,25 +3399,25 @@ var ObjectStackAdapter = class {
2534
3399
  * @returns Promise resolving to array of results
2535
3400
  */
2536
3401
  /**
2537
- * Cross-object transactional batch (ObjectStack #1604 / ADR-0034 item 4).
3402
+ * Cross-object transactional batch (objectstack-ai/objectstack#1604 / ADR-0034 item 4).
2538
3403
  * Runs the operations in ONE server transaction — commit all or roll back
2539
3404
  * all. A field value of `{ $ref: <earlier op index> }` resolves to that op's
2540
3405
  * created id, so a child can reference its parent created earlier in the same
2541
3406
  * batch (master-detail).
2542
3407
  *
2543
3408
  * Transport: the published `@objectstack/client` SDK method
2544
- * `data.batchTransaction` (framework #3271; shipped since client v16, our
3409
+ * `data.batchTransaction` (objectstack-ai/objectstack#3271; shipped since client v16, our
2545
3410
  * dependency floor). Per AGENTS.md §7 data always flows through the client —
2546
3411
  * never a hand-rolled `fetch('/api/v1/batch')`.
2547
3412
  *
2548
- * Fallback decision — declarative capability negotiation (framework #3298 /
3413
+ * Fallback decision — declarative capability negotiation (objectstack-ai/objectstack#3298 /
2549
3414
  * objectui #2693). At connect() we read `capabilities.transactionalBatch`
2550
3415
  * from discovery:
2551
3416
  * - Declared `true` → the backend GUARANTEES atomicity (declared ===
2552
3417
  * enforced). We TRUST it: any batch failure — including 404/405/501 —
2553
3418
  * surfaces as a real error. No non-atomic client-side compensation. This
2554
3419
  * is the path modern backends take.
2555
- * - Declared `false`, or ABSENT (backend predates #3298) → we can't rely on
3420
+ * - Declared `false`, or ABSENT (backend predates objectstack-ai/objectstack#3298) → we can't rely on
2556
3421
  * server atomicity, so we keep the legacy behaviour: on 404/405 (no
2557
3422
  * endpoint) or 501 (runtime without transactions) degrade to the
2558
3423
  * client-side, NON-atomic {@link emulateBatchTransaction} so a save is
@@ -2976,9 +3841,10 @@ var ObjectStackAdapter = class {
2976
3841
  const res = await this.fetchImpl(url, { method: "GET", headers });
2977
3842
  if (!res.ok) {
2978
3843
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
2979
- const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
3844
+ const envelope = readErrorEnvelope(errorBody);
3845
+ const err = new Error(envelope.message ?? res.statusText);
2980
3846
  err.status = res.status;
2981
- err.code = errorBody?.code ?? errorBody?.error?.code;
3847
+ err.code = envelope.code;
2982
3848
  err.httpStatus = res.status;
2983
3849
  throw err;
2984
3850
  }
@@ -3028,16 +3894,51 @@ var ObjectStackAdapter = class {
3028
3894
  queryParams.set("searchFields", request.searchFields.join(","));
3029
3895
  }
3030
3896
  }
3897
+ return this.fetchExportBlob(resource, queryParams);
3898
+ }
3899
+ /**
3900
+ * Download the server's import template:
3901
+ * `GET /api/v1/data/:object/export?template=true` (objectui#9600).
3902
+ *
3903
+ * Same route as {@link exportDownload}, with the server's template switch. It
3904
+ * sends only `template=true`: the server refuses the export's row parameters
3905
+ * on a template request, and its answer is always an xlsx workbook.
3906
+ *
3907
+ * The server writes the template's labels in the request's locale, and the
3908
+ * import accepts a translated label only in its own request's locale. The
3909
+ * import request goes through `@objectstack/client`, which stamps
3910
+ * `Accept-Language` with the client's locale when one is set (and the host's
3911
+ * `fetch` stamps its own otherwise). This request bypasses the client, so it
3912
+ * stamps the client's locale the same way. Both requests then carry the same
3913
+ * locale.
3914
+ */
3915
+ async downloadImportTemplate(resource) {
3916
+ const locale = this.client.getLocale();
3917
+ return this.fetchExportBlob(
3918
+ resource,
3919
+ new URLSearchParams({ template: "true" }),
3920
+ locale ? { "Accept-Language": locale } : void 0
3921
+ );
3922
+ }
3923
+ /**
3924
+ * GET `/api/v1/data/:object/export` with the given query and answer the body
3925
+ * as a Blob. A failure throws an `Error` that carries the server's `status`
3926
+ * and its ADR-0112 `code`. The one request path {@link exportDownload} and
3927
+ * {@link downloadImportTemplate} share.
3928
+ */
3929
+ async fetchExportBlob(resource, queryParams, extraHeaders) {
3031
3930
  const baseUrl = this.baseUrl.replace(/\/$/, "");
3032
3931
  const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
3033
3932
  const dataPath = hasApiVersionSuffix ? "/data" : "/api/v1/data";
3034
3933
  const url = `${baseUrl}${dataPath}/${encodeURIComponent(resource)}/export?${queryParams.toString()}`;
3035
- const headers = { ...this.getAuthHeaders() };
3934
+ const headers = { ...this.getAuthHeaders(), ...extraHeaders };
3036
3935
  const res = await this.fetchImpl(url, { method: "GET", headers, credentials: "include" });
3037
3936
  if (!res.ok) {
3038
3937
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
3039
- const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
3938
+ const envelope = readErrorEnvelope(errorBody);
3939
+ const err = new Error(envelope.message ?? res.statusText);
3040
3940
  err.status = res.status;
3941
+ err.code = envelope.code;
3041
3942
  throw err;
3042
3943
  }
3043
3944
  return await res.blob();
@@ -3058,7 +3959,8 @@ var ObjectStackAdapter = class {
3058
3959
  if (Array.isArray(params.$filter)) {
3059
3960
  options.filters = translateFilterArray(params.$filter);
3060
3961
  } else {
3061
- options.filters = convertFiltersToAST(params.$filter);
3962
+ const lowered = convertFiltersToAST(params.$filter);
3963
+ if (lowered !== void 0) options.filters = lowered;
3062
3964
  }
3063
3965
  }
3064
3966
  }
@@ -3178,13 +4080,16 @@ var ObjectStackAdapter = class {
3178
4080
  });
3179
4081
  if (!res.ok) {
3180
4082
  const errBody = await res.json().catch(() => ({ message: res.statusText }));
3181
- const err = new Error(errBody?.error?.message || errBody?.message || res.statusText);
4083
+ const envelope = readErrorEnvelope(errBody);
4084
+ const err = new Error(envelope.message ?? res.statusText);
3182
4085
  err.status = res.status;
4086
+ err.code = envelope.code;
3183
4087
  throw err;
3184
4088
  }
3185
4089
  const body = await res.json();
3186
4090
  const data = body && typeof body === "object" && "success" in body && "data" in body ? body.data : body;
3187
- return data && typeof data === "object" && "item" in data ? data.item : data;
4091
+ const item = data && typeof data === "object" && "item" in data ? data.item : data;
4092
+ return attachObjectSortability(item, data && typeof data === "object" ? data.sortability : void 0);
3188
4093
  }
3189
4094
  /**
3190
4095
  * List every registered object (code- and DB-defined) from the metadata
@@ -3366,25 +4271,94 @@ var ObjectStackAdapter = class {
3366
4271
  }
3367
4272
  }
3368
4273
  /**
3369
- * Persist a view definition for an object.
4274
+ * Persist a toolbar-driven view config patch — density, column widths,
4275
+ * sort, hidden columns, inline edit. Symmetric counterpart to
4276
+ * {@link getView}: writes the row to the server metadata store via
4277
+ * `client.meta.saveItem`, then invalidates the matching cache entry so the
4278
+ * next {@link getView} reflects the new payload. Returns the persisted item
4279
+ * when the server echoes it, otherwise undefined.
4280
+ *
4281
+ * Called from exactly ONE production site — `ObjectView`'s
4282
+ * `persistViewPatch`, for the toolbar toggle — but that ONE call site
4283
+ * fires for BOTH kinds of active tab: a code-defined **system** view (no
4284
+ * row of its own yet) and a genuinely user-created **saved** view (already
4285
+ * has a row — the toggle is editing ITS OWN definition, not laying an
4286
+ * overlay on top of it). Which one a given call means is NOT re-derived
4287
+ * here from the write's shape (objectui#4227's own lesson: shape inference
4288
+ * on this namespace is exactly what let a system view masquerade as
4289
+ * saved) — the caller already knows, via the same `isSavedViewId`
4290
+ * classification that gates the switcher's readonly flag and its five
4291
+ * mutating handlers, and passes it as {@link opts.isSavedView}.
3370
4292
  *
3371
- * Symmetric counterpart to {@link getView}: writes the view to the
3372
- * server metadata store via `client.meta.saveItem`, then invalidates
3373
- * the matching cache entry so the next {@link getView} reflects the
3374
- * new payload. Returns the persisted item when the server echoes it,
3375
- * otherwise undefined.
4293
+ * - `isSavedView` false/omitted (system-view target, the common case and
4294
+ * the default for backward compatibility): stamps
4295
+ * {@link VIEW_OVERLAY_MARKER} so {@link listViews} excludes the row —
4296
+ * the original objectui#4227 fix.
4297
+ * - `isSavedView` true: the marker is withheld. Stamping it here would
4298
+ * flag the saved view's OWN row as a personalization overlay, and
4299
+ * `listViews()` would exclude it on the very next read — the user's own
4300
+ * view would vanish from the switcher the moment they toggled its
4301
+ * density (objectui#4227 follow-up, PM review on PR #4713, measured:
4302
+ * `persistViewPatch` has no gate on which kind of tab is active, and
4303
+ * this method writes to the exact same `(type='view', name=viewId)` key
4304
+ * {@link createView}/the ADR-0034 `viewEnvelope` seam already used for
4305
+ * that view, so the write is an upsert onto the saved view's row, not a
4306
+ * new one).
3376
4307
  *
3377
- * Used by ObjectView for "live" toolbar persistence (density,
3378
- * column widths, sort, etc.) and by the View Config Panel for
3379
- * explicit saves.
4308
+ * Per objectstack#7494's ruling, the overlay this writes (system-view
4309
+ * case) is ORG-WIDE shared view settings, not a per-user preference — a
4310
+ * true per-user scope is a parked v18 direction on the platform side.
3380
4311
  *
3381
4312
  * @param objectName - Object name (e.g. 'lead')
3382
4313
  * @param viewId - View identifier (e.g. 'all_leads')
3383
4314
  * @param config - Full view definition to persist
4315
+ * @param opts.isSavedView - Whether `viewId` already names a saved view
4316
+ * (vs. a system view being personalized for the first time). Omit /
4317
+ * `false` for the default overlay-marking behavior.
3384
4318
  */
3385
- async updateViewConfig(objectName, viewId, config) {
4319
+ /**
4320
+ * [ADR-0066] Tell this adapter which system capabilities the session holds.
4321
+ *
4322
+ * Pass `undefined` for "no answer was reported" (a backend predating
4323
+ * ADR-0066, or no permission provider mounted). Pass `[]` for a real,
4324
+ * reported, empty grant — {@link maySetViewConfig} treats those two
4325
+ * differently and callers must not collapse them, which is the same
4326
+ * distinction `MePermissionsProvider` preserves natively (objectui#4656).
4327
+ */
4328
+ setSystemCapabilities(capabilities) {
4329
+ this.systemCapabilities = capabilities ? [...capabilities] : void 0;
4330
+ }
4331
+ /**
4332
+ * May this session write ORG-WIDE view configuration?
4333
+ *
4334
+ * **Unknown fails OPEN**, deliberately, and this is the one judgement in the
4335
+ * gate worth reading twice. It is the doctrine `PermissionContextValue`
4336
+ * .`hasCapabilities` states for every ADR-0066 gate in this repo
4337
+ * (objectui#4656, framework#3923): the server enforces `manage_metadata` on
4338
+ * the metadata door regardless of what the client believes, so a client-side
4339
+ * denial on MISSING DATA cannot protect anything the server was not already
4340
+ * protecting — it can only break a permitted user. Failing closed here would
4341
+ * have refused the write on every deployment predating ADR-0066 and every
4342
+ * host with no permission provider, which is "break the write for everyone"
4343
+ * wearing the costume of a security fix.
4344
+ *
4345
+ * A REPORTED empty array gates strictly: "holds nothing" is a real answer.
4346
+ */
4347
+ maySetViewConfig() {
4348
+ if (this.systemCapabilities === void 0) return true;
4349
+ return this.systemCapabilities.includes(VIEW_CONFIG_CAPABILITY);
4350
+ }
4351
+ async updateViewConfig(objectName, viewId, config, opts) {
4352
+ if (!this.maySetViewConfig()) {
4353
+ throw new ViewConfigPermissionDeniedError(objectName, viewId);
4354
+ }
3386
4355
  await this.connect();
3387
- const merged = { ...config || {}, object: config?.object || objectName, name: viewId };
4356
+ const merged = {
4357
+ ...config || {},
4358
+ object: config?.object || objectName,
4359
+ name: viewId,
4360
+ ...opts?.isSavedView ? {} : { [VIEW_OVERLAY_MARKER]: true }
4361
+ };
3388
4362
  const result = await this.client.meta.saveItem(
3389
4363
  "view",
3390
4364
  viewId,
@@ -3405,6 +4379,42 @@ var ObjectStackAdapter = class {
3405
4379
  * the metadata index is name-only, not field-typed, so the route has no
3406
4380
  * `?object=` to push the filter down into. {@link listViewOverrides}
3407
4381
  * reads the same rows through the same accessor.
4382
+ *
4383
+ * ## Every failure still degrades to an empty list — and now says which kind
4384
+ * ## of failure it was (objectui#8151)
4385
+ *
4386
+ * The degrade is unchanged and deliberate, exactly as it is one method over
4387
+ * in {@link listImportMappings}: a host with no metadata door keeps answering
4388
+ * `[]` rather than breaking the page. What changed is that the OTHER failures
4389
+ * no longer render as that one.
4390
+ *
4391
+ * This method's empty list is read as *this object has no saved views*, and
4392
+ * that reading reaches the user in two places measured on this tree: the list
4393
+ * view switcher simply shows fewer tabs, and `@object-ui/core`'s
4394
+ * `elementDataSourceViewNotFoundMessage` states it outright. So a session
4395
+ * whose token lapsed mid-read was shown an object that appears to have no
4396
+ * saved views AT ALL — including ones the user created — with a
4397
+ * `console.warn` as the only discriminator, in a console nothing in the UI
4398
+ * points at. That is the silence objectui#7741 removed from the sibling after
4399
+ * it produced a confident wrong diagnosis in objectstack#14026.
4400
+ *
4401
+ * The `catch` now asks {@link classifyViewsFailure} what the failure WAS,
4402
+ * reading the error's own ADR-0112 `code` and status — never "is the result
4403
+ * empty", which is what both conditions produce and so can never tell them
4404
+ * apart — and anything that is not the doorless shape is announced on
4405
+ * {@link onMetadataReadWarning}.
4406
+ *
4407
+ * ⛔ That classifier is a SEPARATE reading, not a second caller of the
4408
+ * sibling's: `view`'s quiet set is strictly smaller, because the arm
4409
+ * `listImportMappings` is built around (*an older server without this kind*)
4410
+ * has no members here. See {@link classifyViewsFailure} for the measurement.
4411
+ *
4412
+ * ⛔ The RETURN is untouched. This method has answered `Promise<any[]>`, never
4413
+ * throwing, since `@object-ui/data-objectstack@17.1.0`; a consumer that reads
4414
+ * nothing new sees exactly what it saw before, including on the loud arms.
4415
+ * Applying framework objectstack#13906 decision 1 option A — *a thing that could not be
4416
+ * READ is not a thing that is ABSENT* — is done by ADDING a channel, not by
4417
+ * moving that contract.
3408
4418
  */
3409
4419
  async listViews(objectName, options) {
3410
4420
  await this.connect();
@@ -3423,23 +4433,42 @@ var ObjectStackAdapter = class {
3423
4433
  const spec = v.list ?? v;
3424
4434
  if (viewItemObjectName(v) !== objectName) return false;
3425
4435
  const viewKind = v.viewKind ?? spec?.viewKind;
3426
- return !(viewKind && FORM_FAMILY.has(viewKind));
4436
+ if (viewKind && FORM_FAMILY.has(viewKind)) return false;
4437
+ if (isPersonalizationOverlayRow(v, spec)) return false;
4438
+ return true;
3427
4439
  }).map((v) => {
3428
4440
  const spec = v.list ?? v;
3429
4441
  const isDraft = v._draft === true || spec?._draft === true;
3430
4442
  if (spec && spec.config && typeof spec.config === "object") {
3431
- return {
4443
+ const row = {
3432
4444
  ...spec.config,
3433
4445
  name: spec.name ?? spec.config.name,
3434
- label: spec.label ?? spec.config.label,
3435
- isDefault: !!spec.isDefault,
3436
- ...isDraft ? { _draft: true } : {}
4446
+ label: spec.label ?? spec.config.label
3437
4447
  };
4448
+ if (spec.object !== void 0) row.object = spec.object;
4449
+ for (const key of VIEW_ITEM_ROUND_TRIP_KEYS) {
4450
+ if (spec[key] !== void 0) row[key] = spec[key];
4451
+ }
4452
+ row.isDefault = !!spec.isDefault;
4453
+ if (isDraft) row._draft = true;
4454
+ return row;
3438
4455
  }
3439
4456
  return isDraft ? { ...spec, _draft: true } : spec;
3440
4457
  });
3441
4458
  } catch (err) {
3442
4459
  console.warn("[OBJECTSTACKDataSource] listViews failed:", err);
4460
+ const failure = classifyViewsFailure(err);
4461
+ if (failure.kind !== "not-served") {
4462
+ this.emitMetadataReadWarning({
4463
+ operation: "listViews",
4464
+ kind: "view",
4465
+ objectName,
4466
+ reason: failure.kind,
4467
+ ...failure.code !== void 0 ? { code: failure.code } : {},
4468
+ ...failure.status !== void 0 ? { status: failure.status } : {},
4469
+ ...failure.message !== void 0 ? { message: failure.message } : {}
4470
+ });
4471
+ }
3443
4472
  return [];
3444
4473
  }
3445
4474
  }
@@ -3458,11 +4487,36 @@ var ObjectStackAdapter = class {
3458
4487
  }
3459
4488
  /**
3460
4489
  * List registered import `mapping` artifacts targeting a given object
3461
- * (framework #2611). Reads the `mapping` metadata kind via the overlay API
4490
+ * (objectstack-ai/objectstack#2611). Reads the `mapping` metadata kind via the overlay API
3462
4491
  * and filters by `targetObject` client-side (the metadata index is
3463
- * name-only). Feeds the import wizard's "saved mapping" selector; a failure
3464
- * (older server without the `mapping` kind) degrades to an empty list, so
3465
- * the selector simply doesn't appear.
4492
+ * name-only). Feeds the import wizard's "saved mapping" selector.
4493
+ *
4494
+ * ## Every failure still degrades to an empty list — and now says which kind
4495
+ * ## of failure it was (objectui#7741)
4496
+ *
4497
+ * The degrade is unchanged and deliberate: an older server without the
4498
+ * `mapping` kind must not break the wizard, so it keeps answering `[]` and
4499
+ * the selector simply doesn't appear. That is a real, supported deployment
4500
+ * shape and it stays quiet.
4501
+ *
4502
+ * What changed is that the OTHER failures no longer render as that one. A
4503
+ * refused door and a broken one used to be indistinguishable, in the UI, from
4504
+ * "no mapping is registered" — both were an empty list behind a
4505
+ * `console.warn` nothing pointed at — and that silence produced a confident
4506
+ * wrong diagnosis in a careful reporter (objectstack#14026 was filed, routed
4507
+ * and worked by two seats against a wizard that had been correct since
4508
+ * 17.1.0). So the `catch` now asks {@link classifyImportMappingsFailure} what
4509
+ * the failure WAS, reading the error's own ADR-0112 `code` and status —
4510
+ * never "is the result empty", which is what both conditions produce and so
4511
+ * can never tell them apart — and anything that is not the supported
4512
+ * kind-absent shape is announced on {@link onMetadataReadWarning}.
4513
+ *
4514
+ * ⛔ The RETURN is untouched. This method has answered `Promise<any[]>`, never
4515
+ * throwing, since `@object-ui/data-objectstack@17.1.0`; a consumer that reads
4516
+ * nothing new sees exactly what it saw before, including on the loud arms.
4517
+ * Applying framework objectstack#13906 decision 1 option A — *a thing that could not be
4518
+ * READ is not a thing that is ABSENT* — is done by ADDING a channel, not by
4519
+ * moving that contract.
3466
4520
  */
3467
4521
  async listImportMappings(objectName) {
3468
4522
  await this.connect();
@@ -3472,6 +4526,18 @@ var ObjectStackAdapter = class {
3472
4526
  return items.filter((m) => m && m.targetObject === objectName);
3473
4527
  } catch (err) {
3474
4528
  console.warn("[OBJECTSTACKDataSource] listImportMappings failed:", err);
4529
+ const failure = classifyImportMappingsFailure(err);
4530
+ if (failure.kind !== "not-served") {
4531
+ this.emitMetadataReadWarning({
4532
+ operation: "listImportMappings",
4533
+ kind: "mapping",
4534
+ objectName,
4535
+ reason: failure.kind,
4536
+ ...failure.code !== void 0 ? { code: failure.code } : {},
4537
+ ...failure.status !== void 0 ? { status: failure.status } : {},
4538
+ ...failure.message !== void 0 ? { message: failure.message } : {}
4539
+ });
4540
+ }
3475
4541
  return [];
3476
4542
  }
3477
4543
  }
@@ -3698,7 +4764,7 @@ var ObjectStackAdapter = class {
3698
4764
  try {
3699
4765
  const cacheKey = `app:${appId}`;
3700
4766
  return await this.metadataCache.get(cacheKey, async () => {
3701
- const result = await this.client.meta.getItem("apps", appId);
4767
+ const result = await this.client.meta.getItem("app", appId);
3702
4768
  if (result && result.item) return result.item;
3703
4769
  return result ?? null;
3704
4770
  });
@@ -3710,8 +4776,13 @@ var ObjectStackAdapter = class {
3710
4776
  * Ask the by-name meta app route WHY an app is not in this session's app list
3711
4777
  * (objectui#4252).
3712
4778
  *
3713
- * `GET /api/v1/meta/apps` is filtered per session server-side
3714
- * (`filterAppForUser`), so an app withheld by `requiredPermissions` and an app
4779
+ * The app LIST is the generic metadata list route `GET /api/v1/meta/:type`,
4780
+ * requested with the singular type segment `app` — the same address this
4781
+ * method appends a name to below, and the one `MetadataProvider` reads its
4782
+ * items from. The server filters that list per session in `filterAppForUser`
4783
+ * (`packages/rest/src/rest-server.ts`, applied inside the `:type` list handler
4784
+ * once the type segment resolves to `app`), so an app withheld by
4785
+ * `requiredPermissions` and an app
3715
4786
  * that does not exist are byte-identical there: both are simply absent. A
3716
4787
  * console reading only that list has one fact and two conditions, and it
3717
4788
  * renders its copy for the wrong one — "it may still be publishing" over a
@@ -3721,11 +4792,17 @@ var ObjectStackAdapter = class {
3721
4792
  *
3722
4793
  * The maintainer ruling (2026-08-12) put the answer on the BY-NAME route
3723
4794
  * rather than in the list, so the enumeration surface is not widened past what
3724
- * a by-name probe already implies (objectstack#8013 / PR #8135): an app that
4795
+ * a by-name probe already implies (objectstack#8013 / PR objectstack-ai/objectstack#8135): an app that
3725
4796
  * exists and whose `requiredPermissions` the session lacks answers `403` with
3726
4797
  * `PERMISSION_DENIED` in the declared envelope, and absence — a nonexistent
3727
- * name, an unpublished app, an app gated by an absent optional service —
3728
- * keeps answering `404 RESOURCE_NOT_FOUND`.
4798
+ * name, an unpublished app, an app gated by an absent optional service — is
4799
+ * one undifferentiated answer.
4800
+ *
4801
+ * ⚠️ That absence is NOT uniformly a `404`, which this docstring asserted
4802
+ * until objectui#9262 measured it: the 404 covers an app that EXISTS and is
4803
+ * withheld for a non-permission reason, while a name that resolves to nothing
4804
+ * answers `200` with an envelope carrying no `item`. Both are absence and both
4805
+ * read as `not_found`; see {@link metaEnvelopeCarriesItem} for the measurement.
3729
4806
  *
3730
4807
  * ## Why this is a separate method and not a flavour of {@link getApp}
3731
4808
  *
@@ -3736,19 +4813,41 @@ var ObjectStackAdapter = class {
3736
4813
  * be cached beside a document about the APP — one grant, and a cached
3737
4814
  * denial outlives the session it described.
3738
4815
  *
3739
- * Nothing here throws: a probe that cannot reach an answer returns `unknown`
3740
- * and the caller keeps whatever it was already showing. Only the measured
3741
- * `code` produces `denied` — never a status, never a message (objectui#4408).
4816
+ * Nothing here throws: every outcome is a verdict the caller renders a screen
4817
+ * from, never a catch block. Only the measured `code` produces `denied` —
4818
+ * never a status, never a message (objectui#4408).
4819
+ *
4820
+ * ## objectui#9262 — what this method stopped folding away
4821
+ *
4822
+ * It used to answer `granted` whenever the call did not throw, and `unknown`
4823
+ * for everything that did. Both halves were wrong in the same direction, and
4824
+ * the first was measured on a real server before this change landed:
4825
+ *
4826
+ * - **an app that does not exist answers `200` with the envelope minus its
4827
+ * `item`**, so "did not throw" reported `granted` for a name with nothing
4828
+ * behind it — the shape issue objectui#9262 listed as cause 8 and expected
4829
+ * to be structural. It is the ordinary case: every typo, every never-created
4830
+ * app and every unpublished draft took it. See
4831
+ * {@link metaEnvelopeCarriesItem} for the measurement and the server-side
4832
+ * mechanism.
4833
+ * - a 404 absence and an unreachable server were one `unknown`, though the
4834
+ * transport had already told them apart.
4835
+ *
4836
+ * The verdicts now say only what was obtained: a served document, a refusal
4837
+ * naming permission, an answer that there is no such app here, or no answer
4838
+ * at all. `unknown` is left for the one case where nothing was asked.
3742
4839
  *
3743
4840
  * @param appName - the app name as it appears in the URL segment
3744
4841
  */
3745
4842
  async probeAppAccess(appName) {
3746
4843
  if (!appName) return "unknown";
3747
4844
  try {
3748
- await this.client.meta.getItem("app", appName);
3749
- return "granted";
4845
+ const envelope = await this.client.meta.getItem("app", appName);
4846
+ return metaEnvelopeCarriesItem(envelope) ? "granted" : "not_found";
3750
4847
  } catch (err) {
3751
- return isAppPermissionDeniedError(err) ? "denied" : "unknown";
4848
+ if (isAppPermissionDeniedError(err)) return "denied";
4849
+ if (isMetaItemAbsentError(err)) return "not_found";
4850
+ return "unreachable";
3752
4851
  }
3753
4852
  }
3754
4853
  /**
@@ -3761,7 +4860,7 @@ var ObjectStackAdapter = class {
3761
4860
  try {
3762
4861
  const cacheKey = `page:${pageId}`;
3763
4862
  return await this.metadataCache.get(cacheKey, async () => {
3764
- const result = await this.client.meta.getItem("pages", pageId);
4863
+ const result = await this.client.meta.getItem("page", pageId);
3765
4864
  if (result && result.item) return result.item;
3766
4865
  return result ?? null;
3767
4866
  });
@@ -3798,7 +4897,8 @@ var ObjectStackAdapter = class {
3798
4897
  * Uses `this.client.analytics.query()` from @objectstack/client to leverage
3799
4898
  * the SDK's built-in auth, headers, and fetch configuration.
3800
4899
  * Falls back to client-side aggregation via find() if the analytics endpoint
3801
- * is not available.
4900
+ * is not available. Throws {@link AnalyticsResultShapeError} when it answers
4901
+ * with anything but an `AnalyticsResult` carrying `rows`.
3802
4902
  */
3803
4903
  async aggregate(resource, params) {
3804
4904
  await this.connect();
@@ -3807,7 +4907,11 @@ var ObjectStackAdapter = class {
3807
4907
  const queryAst = {};
3808
4908
  if (Array.isArray(params.groupBy)) queryAst.groupBy = params.groupBy;
3809
4909
  if (Array.isArray(params.aggregations)) queryAst.aggregations = params.aggregations;
3810
- if (params.where !== void 0) queryAst.where = params.where;
4910
+ if (params.where !== void 0) {
4911
+ assertSpecShapeWhereIsFilterAst(params.where, resource);
4912
+ queryAst.where = params.where;
4913
+ }
4914
+ assertNoAnalyticsKeysOnSpecShape(params, resource);
3811
4915
  if (typeof params.limit === "number") queryAst.limit = params.limit;
3812
4916
  const result = await this.client.data.query(resource, queryAst);
3813
4917
  if (Array.isArray(result)) return result;
@@ -3815,6 +4919,7 @@ var ObjectStackAdapter = class {
3815
4919
  if (Array.isArray(result?.data)) return result.data;
3816
4920
  return [];
3817
4921
  }
4922
+ const analyticsWhere = params.filter ? lowerAnalyticsFilterForWire(params.filter, resource) : void 0;
3818
4923
  try {
3819
4924
  const measureName = params.function === "count" ? "count" : `${params.field}_${params.function}`;
3820
4925
  const valueKey = this.aggregateValueKey(params);
@@ -3824,11 +4929,12 @@ var ObjectStackAdapter = class {
3824
4929
  // When groupBy is '_all' no dimensions are needed (single-bucket).
3825
4930
  dimensions: params.groupBy && params.groupBy !== "_all" ? [params.groupBy] : []
3826
4931
  };
3827
- if (params.filter) {
3828
- payload.where = params.filter;
4932
+ if (analyticsWhere !== void 0) payload.where = analyticsWhere;
4933
+ const contractResult = await this.client.analytics.query(payload);
4934
+ if (!Array.isArray(contractResult?.rows)) {
4935
+ throw new AnalyticsResultShapeError(resource, contractResult);
3829
4936
  }
3830
- const data = await this.client.analytics.query(payload);
3831
- 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 : [];
4937
+ const rawRows = contractResult.rows;
3832
4938
  const measureMissing = rawRows.length > 0 && rawRows.every((row) => {
3833
4939
  if (row == null) return true;
3834
4940
  if (measureName in row && row[measureName] != null) return false;
@@ -3847,16 +4953,54 @@ var ObjectStackAdapter = class {
3847
4953
  return mapped;
3848
4954
  });
3849
4955
  } catch (e) {
4956
+ if (e instanceof AnalyticsResultShapeError) throw e;
3850
4957
  const failure = classifyAnalyticsFailure(e);
3851
4958
  if (failure.kind === "rejected") {
3852
4959
  throw new AnalyticsQueryRejectedError(failure.message, failure.code);
3853
4960
  }
4961
+ if (failure.kind === "unauthenticated") {
4962
+ throw new AnalyticsUnauthenticatedError({
4963
+ serverCode: failure.code,
4964
+ serverMessage: failure.message
4965
+ });
4966
+ }
4967
+ if (failure.kind === "cube-not-found") {
4968
+ throw e;
4969
+ }
3854
4970
  if (failure.kind === "not-installed") {
3855
4971
  this.warnAnalyticsCapabilityOnce(failure.message);
3856
4972
  }
3857
4973
  return await this.aggregateViaFind(resource, params);
3858
4974
  }
3859
4975
  }
4976
+ /**
4977
+ * Answer a grouped list view's GROUP HEADER query (objectui#7189) — see
4978
+ * `DataSource.queryGroupHeaders` in `@object-ui/types` for the contract.
4979
+ *
4980
+ * The compiled query rides the platform's EXISTING data door verbatim:
4981
+ * `client.data.query()` posts it to `POST /data/:object/query`, whose
4982
+ * `findData` routes a body carrying `groupBy` / `aggregations` to
4983
+ * `engine.aggregate` and answers the header rows as `records` (the door the
4984
+ * spec half of objectstack#14556 names, pinned platform-side by
4985
+ * objectstack#15330). No new route, no new wire shape.
4986
+ *
4987
+ * ⛔ No fallback, deliberately — unlike {@link aggregate}. A header query that
4988
+ * degraded to bucketing a fetched page would answer page slices under a name
4989
+ * that promises the query's own numbers, which is the defect this member
4990
+ * exists to end. A failure is thrown to the caller, and a body without a
4991
+ * `records` array is refused rather than read as "no groups".
4992
+ */
4993
+ async queryGroupHeaders(resource, query) {
4994
+ await this.connect();
4995
+ const result = await this.client.data.query(resource, query);
4996
+ const records = result?.records;
4997
+ if (!Array.isArray(records)) {
4998
+ throw new Error(
4999
+ `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.`
5000
+ );
5001
+ }
5002
+ return records;
5003
+ }
3860
5004
  /**
3861
5005
  * Client-side aggregation over a server-scoped `find()` — the fallback used
3862
5006
  * whenever the analytics endpoint cannot answer (capability absent, network
@@ -3930,14 +5074,42 @@ var ObjectStackAdapter = class {
3930
5074
  body: JSON.stringify(requestBody)
3931
5075
  });
3932
5076
  if (!res.ok) {
3933
- let detail = "";
5077
+ let errBody;
3934
5078
  try {
3935
- const errBody = await res.json();
3936
- detail = errBody?.message || errBody?.error || JSON.stringify(errBody);
5079
+ errBody = await res.json();
3937
5080
  } catch {
3938
5081
  }
3939
- if (res.status === 501 || res.status === 404) {
3940
- throw new AnalyticsNotInstalledError("POST /analytics/dataset/query", detail || void 0);
5082
+ const { code: serverCode, message: serverMessage } = readAnalyticsErrorEnvelope(errBody);
5083
+ const detail = serverMessage || (errBody === void 0 ? "" : JSON.stringify(errBody));
5084
+ const surface = "POST /analytics/dataset/query";
5085
+ const requestedDatasetName = typeof dataset === "string" ? dataset : typeof dataset?.name === "string" ? String(dataset.name) : void 0;
5086
+ if (errorCodeIsAnyOf({ code: serverCode }, ["NOT_IMPLEMENTED", "ROUTE_NOT_FOUND"])) {
5087
+ throw new AnalyticsNotInstalledError(surface, serverMessage, serverCode);
5088
+ }
5089
+ if (errorCodeIs({ code: serverCode }, "NOT_FOUND")) {
5090
+ throw new AnalyticsDatasetNotFoundError({
5091
+ datasetName: requestedDatasetName,
5092
+ serverCode,
5093
+ serverMessage
5094
+ });
5095
+ }
5096
+ if (errorCodeIs({ code: serverCode }, "UNAUTHENTICATED")) {
5097
+ throw new AnalyticsUnauthenticatedError({ serverCode, serverMessage });
5098
+ }
5099
+ if (errorCodeIs({ code: serverCode }, "PERMISSION_DENIED")) {
5100
+ throw new AnalyticsForbiddenError({
5101
+ serverCode,
5102
+ serverMessage,
5103
+ datasetName: requestedDatasetName
5104
+ });
5105
+ }
5106
+ if (serverCode === void 0) {
5107
+ if (ANALYTICS_ABSENT_STATUSES.includes(res.status)) {
5108
+ throw new AnalyticsNotInstalledError(surface, detail || void 0);
5109
+ }
5110
+ if (res.status === 401) {
5111
+ throw new AnalyticsUnauthenticatedError({ serverMessage });
5112
+ }
3941
5113
  }
3942
5114
  throw new Error(`Dataset query failed: ${res.status} ${res.statusText}${detail ? ` \u2014 ${detail}` : ""}`);
3943
5115
  }
@@ -4071,9 +5243,10 @@ var ObjectStackAdapter = class {
4071
5243
  }
4072
5244
  });
4073
5245
  if (!response.ok) {
4074
- const error = await response.json().catch(() => ({ message: response.statusText }));
5246
+ const errorBody = await response.json().catch(() => ({ message: response.statusText }));
5247
+ const envelope = readErrorEnvelope(errorBody);
4075
5248
  throw new ObjectStackError(
4076
- error.message || `Upload failed with status ${response.status}`,
5249
+ envelope.message ?? `Upload failed with status ${response.status}`,
4077
5250
  "UPLOAD_ERROR",
4078
5251
  response.status
4079
5252
  );
@@ -4113,9 +5286,10 @@ var ObjectStackAdapter = class {
4113
5286
  }
4114
5287
  });
4115
5288
  if (!response.ok) {
4116
- const error = await response.json().catch(() => ({ message: response.statusText }));
5289
+ const errorBody = await response.json().catch(() => ({ message: response.statusText }));
5290
+ const envelope = readErrorEnvelope(errorBody);
4117
5291
  throw new ObjectStackError(
4118
- error.message || `Upload failed with status ${response.status}`,
5292
+ envelope.message ?? `Upload failed with status ${response.status}`,
4119
5293
  "UPLOAD_ERROR",
4120
5294
  response.status
4121
5295
  );
@@ -4196,31 +5370,47 @@ function createObjectStackAdapter(config) {
4196
5370
  export {
4197
5371
  API_ACCESS_DENIED_CODES,
4198
5372
  APP_PERMISSION_DENIED_CODE,
5373
+ AnalyticsDatasetNotFoundError,
5374
+ AnalyticsForbiddenError,
5375
+ AnalyticsKeysOnSpecShapeError,
4199
5376
  AnalyticsNotInstalledError,
4200
5377
  AnalyticsQueryRejectedError,
5378
+ AnalyticsResultShapeError,
5379
+ AnalyticsUnauthenticatedError,
4201
5380
  AuthenticationError,
4202
5381
  BulkOperationError,
5382
+ CHOICE_TYPES_REQUIRING_OPTIONS,
4203
5383
  ConcurrentUpdateError,
4204
5384
  ConnectionError,
4205
5385
  DataApiValidationError,
4206
5386
  FILTER_OPERATOR_ALIASES,
4207
- IntegrationManager,
5387
+ META_ITEM_ABSENT_CODE,
4208
5388
  MalformedFilterError,
4209
5389
  MetadataClient,
4210
5390
  MetadataNotFoundError,
5391
+ OBJECT_METADATA_TYPE,
4211
5392
  ObjectStackAdapter,
4212
5393
  ObjectStackError,
4213
- SecurityManager,
4214
- calculateAutoLayout,
5394
+ RELATIONSHIP_TYPES_REQUIRING_REFERENCE,
5395
+ UNRECOGNIZED_DROP_REASON,
5396
+ UnlowerableAnalyticsFilterError,
5397
+ UnloweredAggregateWhereError,
5398
+ VIEW_CONFIG_CAPABILITY,
5399
+ VIEW_OVERLAY_OWNED_KEYS,
5400
+ ViewConfigPermissionDeniedError,
5401
+ assertObjectMetadataWritable,
4215
5402
  classifyAnalyticsFailure,
5403
+ classifyImportMappingsFailure,
5404
+ classifyViewsFailure,
4216
5405
  clearSharedDiscoveryCache,
4217
- createDefaultCanvasConfig,
4218
5406
  createErrorFromResponse,
4219
5407
  createObjectStackAdapter,
4220
5408
  createObjectStackUserStateAdapter,
4221
5409
  createQuietHttpLogger,
5410
+ extractDraftBody,
4222
5411
  formatHttpFailureMessage,
4223
- generateContractManifest,
5412
+ formatMetadataError,
5413
+ formatMetadataIssue,
4224
5414
  getSharedDiscovery,
4225
5415
  is404Error,
4226
5416
  isAnalyticsNotInstalledError,
@@ -4229,12 +5419,13 @@ export {
4229
5419
  isConcurrentUpdateError,
4230
5420
  isErrorType,
4231
5421
  isMalformedFilterError,
5422
+ isMetaItemAbsentError,
4232
5423
  isObjectStackError,
5424
+ isViewConfigPermissionDeniedError,
5425
+ narrowPersonalizationOverlay,
4233
5426
  normaliseClientError,
4234
5427
  readSaveAdvisories,
4235
5428
  readTransactionalBatchCapability,
4236
5429
  serializeOrderBy,
4237
- snapToGrid,
4238
- validatePluginContract,
4239
5430
  viewItemObjectName
4240
5431
  };