@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.cjs CHANGED
@@ -24,31 +24,47 @@ var index_exports = {};
24
24
  __export(index_exports, {
25
25
  API_ACCESS_DENIED_CODES: () => API_ACCESS_DENIED_CODES,
26
26
  APP_PERMISSION_DENIED_CODE: () => APP_PERMISSION_DENIED_CODE,
27
+ AnalyticsDatasetNotFoundError: () => AnalyticsDatasetNotFoundError,
28
+ AnalyticsForbiddenError: () => AnalyticsForbiddenError,
29
+ AnalyticsKeysOnSpecShapeError: () => AnalyticsKeysOnSpecShapeError,
27
30
  AnalyticsNotInstalledError: () => AnalyticsNotInstalledError,
28
31
  AnalyticsQueryRejectedError: () => AnalyticsQueryRejectedError,
32
+ AnalyticsResultShapeError: () => AnalyticsResultShapeError,
33
+ AnalyticsUnauthenticatedError: () => AnalyticsUnauthenticatedError,
29
34
  AuthenticationError: () => AuthenticationError,
30
35
  BulkOperationError: () => BulkOperationError,
36
+ CHOICE_TYPES_REQUIRING_OPTIONS: () => CHOICE_TYPES_REQUIRING_OPTIONS,
31
37
  ConcurrentUpdateError: () => ConcurrentUpdateError,
32
38
  ConnectionError: () => ConnectionError,
33
39
  DataApiValidationError: () => DataApiValidationError,
34
40
  FILTER_OPERATOR_ALIASES: () => FILTER_OPERATOR_ALIASES,
35
- IntegrationManager: () => IntegrationManager,
41
+ META_ITEM_ABSENT_CODE: () => META_ITEM_ABSENT_CODE,
36
42
  MalformedFilterError: () => MalformedFilterError,
37
43
  MetadataClient: () => MetadataClient,
38
44
  MetadataNotFoundError: () => MetadataNotFoundError,
45
+ OBJECT_METADATA_TYPE: () => OBJECT_METADATA_TYPE,
39
46
  ObjectStackAdapter: () => ObjectStackAdapter,
40
47
  ObjectStackError: () => ObjectStackError,
41
- SecurityManager: () => SecurityManager,
42
- calculateAutoLayout: () => calculateAutoLayout,
48
+ RELATIONSHIP_TYPES_REQUIRING_REFERENCE: () => RELATIONSHIP_TYPES_REQUIRING_REFERENCE,
49
+ UNRECOGNIZED_DROP_REASON: () => UNRECOGNIZED_DROP_REASON,
50
+ UnlowerableAnalyticsFilterError: () => UnlowerableAnalyticsFilterError,
51
+ UnloweredAggregateWhereError: () => UnloweredAggregateWhereError,
52
+ VIEW_CONFIG_CAPABILITY: () => VIEW_CONFIG_CAPABILITY,
53
+ VIEW_OVERLAY_OWNED_KEYS: () => VIEW_OVERLAY_OWNED_KEYS,
54
+ ViewConfigPermissionDeniedError: () => ViewConfigPermissionDeniedError,
55
+ assertObjectMetadataWritable: () => assertObjectMetadataWritable,
43
56
  classifyAnalyticsFailure: () => classifyAnalyticsFailure,
57
+ classifyImportMappingsFailure: () => classifyImportMappingsFailure,
58
+ classifyViewsFailure: () => classifyViewsFailure,
44
59
  clearSharedDiscoveryCache: () => clearSharedDiscoveryCache,
45
- createDefaultCanvasConfig: () => createDefaultCanvasConfig,
46
60
  createErrorFromResponse: () => createErrorFromResponse,
47
61
  createObjectStackAdapter: () => createObjectStackAdapter,
48
62
  createObjectStackUserStateAdapter: () => createObjectStackUserStateAdapter,
49
63
  createQuietHttpLogger: () => createQuietHttpLogger,
64
+ extractDraftBody: () => extractDraftBody,
50
65
  formatHttpFailureMessage: () => formatHttpFailureMessage,
51
- generateContractManifest: () => generateContractManifest,
66
+ formatMetadataError: () => formatMetadataError,
67
+ formatMetadataIssue: () => formatMetadataIssue,
52
68
  getSharedDiscovery: () => getSharedDiscovery,
53
69
  is404Error: () => is404Error,
54
70
  isAnalyticsNotInstalledError: () => isAnalyticsNotInstalledError,
@@ -57,17 +73,83 @@ __export(index_exports, {
57
73
  isConcurrentUpdateError: () => isConcurrentUpdateError,
58
74
  isErrorType: () => isErrorType,
59
75
  isMalformedFilterError: () => isMalformedFilterError,
76
+ isMetaItemAbsentError: () => isMetaItemAbsentError,
60
77
  isObjectStackError: () => isObjectStackError,
78
+ isViewConfigPermissionDeniedError: () => isViewConfigPermissionDeniedError,
79
+ narrowPersonalizationOverlay: () => narrowPersonalizationOverlay,
61
80
  normaliseClientError: () => normaliseClientError,
62
81
  readSaveAdvisories: () => readSaveAdvisories,
63
82
  readTransactionalBatchCapability: () => readTransactionalBatchCapability,
64
83
  serializeOrderBy: () => serializeOrderBy,
65
- snapToGrid: () => snapToGrid,
66
- validatePluginContract: () => validatePluginContract,
67
84
  viewItemObjectName: () => viewItemObjectName
68
85
  });
69
86
  module.exports = __toCommonJS(index_exports);
70
87
  var import_client = require("@objectstack/client");
88
+ var import_ui2 = require("@objectstack/spec/ui");
89
+ var import_data4 = require("@objectstack/spec/data");
90
+ var import_data5 = require("@objectstack/spec/data");
91
+ var import_data6 = require("@objectstack/spec/data");
92
+ var import_kernel2 = require("@objectstack/spec/kernel");
93
+
94
+ // src/metadata-client.ts
95
+ var import_api = require("@objectstack/spec/api");
96
+
97
+ // src/object-metadata-write-guard.ts
98
+ var RELATIONSHIP_TYPES_REQUIRING_REFERENCE = ["lookup", "master_detail"];
99
+ var CHOICE_TYPES_REQUIRING_OPTIONS = ["select", "radio"];
100
+ var OBJECT_METADATA_TYPE = "object";
101
+ function isUsableTarget(reference) {
102
+ return typeof reference === "string" && reference.trim() !== "";
103
+ }
104
+ function describeTarget(reference) {
105
+ if (reference === void 0) return "no `reference` key at all";
106
+ if (reference === null) return "a `null` `reference`";
107
+ if (typeof reference !== "string") return `a \`reference\` of type \`${typeof reference}\``;
108
+ if (reference === "") return "an empty `reference`";
109
+ return "a whitespace-only `reference`";
110
+ }
111
+ function hasOptionSource(def) {
112
+ return Array.isArray(def.options) && def.options.length > 0 || typeof def.picklist === "string";
113
+ }
114
+ function describeOptions(options) {
115
+ if (options === void 0) return "no `options` key and no `picklist`";
116
+ if (Array.isArray(options)) return "an empty `options` list and no `picklist`";
117
+ if (options === null) return "a `null` `options` and no `picklist`";
118
+ return `an \`options\` value of type \`${typeof options}\` and no \`picklist\``;
119
+ }
120
+ function fieldEntries(fields) {
121
+ if (Array.isArray(fields)) {
122
+ return fields.flatMap((raw, index) => {
123
+ if (!raw || typeof raw !== "object") return [];
124
+ const record = raw;
125
+ const name = typeof record.name === "string" && record.name ? record.name : `[${index}]`;
126
+ return [{ name, def: record }];
127
+ });
128
+ }
129
+ if (fields && typeof fields === "object") {
130
+ return Object.entries(fields).flatMap(
131
+ ([name, def]) => def && typeof def === "object" ? [{ name, def }] : []
132
+ );
133
+ }
134
+ return [];
135
+ }
136
+ function assertObjectMetadataWritable(type, item, writer) {
137
+ if (String(type) !== OBJECT_METADATA_TYPE) return;
138
+ if (!item || typeof item !== "object") return;
139
+ const fields = item.fields;
140
+ for (const { name, def } of fieldEntries(fields)) {
141
+ if (CHOICE_TYPES_REQUIRING_OPTIONS.includes(String(def.type)) && !hasOptionSource(def)) {
142
+ throw new Error(
143
+ `${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.`
144
+ );
145
+ }
146
+ if (!RELATIONSHIP_TYPES_REQUIRING_REFERENCE.includes(String(def.type))) continue;
147
+ if (isUsableTarget(def.reference)) continue;
148
+ throw new Error(
149
+ `${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.`
150
+ );
151
+ }
152
+ }
71
153
 
72
154
  // src/metadata-client.ts
73
155
  function readSaveAdvisories(body) {
@@ -80,6 +162,25 @@ function readSaveAdvisories(body) {
80
162
  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";
81
163
  });
82
164
  }
165
+ var PROTECTION_ENVELOPE_KEYS = [
166
+ "overlayScope",
167
+ "lock",
168
+ "lockReason",
169
+ "lockSource",
170
+ "lockDocsUrl",
171
+ "provenance",
172
+ "packageId",
173
+ "packageVersion",
174
+ "editable",
175
+ "deletable",
176
+ "resettable"
177
+ ];
178
+ function unrecognizedEnvelopeKeys(body) {
179
+ const shape = import_api.GetMetaItemLayeredResponseSchema.shape;
180
+ return PROTECTION_ENVELOPE_KEYS.filter(
181
+ (key) => body[key] !== void 0 && !shape[key].safeParse(body[key]).success
182
+ );
183
+ }
83
184
  var META_PREFIX = "/meta";
84
185
  var API_PREFIX = "/api/v1";
85
186
  function buildBase(config) {
@@ -92,6 +193,16 @@ function buildBase(config) {
92
193
  }
93
194
  return `${trimmed}${scoped}`;
94
195
  }
196
+ function packagesBaseOf(metaBase) {
197
+ const origin = metaBase.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, "");
198
+ return `${origin}${API_PREFIX}/packages`;
199
+ }
200
+ function unwrapDispatcherEnvelope(body) {
201
+ if (!body || typeof body !== "object") return {};
202
+ const root = body;
203
+ const data = root.data;
204
+ return data && typeof data === "object" && !Array.isArray(data) ? data : root;
205
+ }
95
206
  async function parseError(res) {
96
207
  let body;
97
208
  try {
@@ -124,7 +235,7 @@ var MetadataClient = class _MetadataClient {
124
235
  __publicField(this, "headers");
125
236
  /** ADR-0037: when true, reads render the draft-overlaid world. */
126
237
  __publicField(this, "previewDrafts");
127
- /** #4133 — sink for post-save advisory findings; see the config field. */
238
+ /** #4133 / #5026 — sink for every write door's advisory findings; see the config field. */
128
239
  __publicField(this, "onSaveAdvisory");
129
240
  this.base = buildBase(config);
130
241
  this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
@@ -278,8 +389,20 @@ var MetadataClient = class _MetadataClient {
278
389
  * {@link get} is deliberate and long-standing (the draft envelope's identity
279
390
  * and protection carriers are part of what a draft reader inspects), so it
280
391
  * is preserved by reading the transport directly instead of going through
281
- * `get()`'s unwrap. `unwrapDraftBody` (app-shell) and `unwrapViewDraft`
282
- * (this package) are the shared helpers for taking the body out.
392
+ * `get()`'s unwrap.
393
+ *
394
+ * ⛔ Do NOT hand `.item` to a gate or a write yourself: the body this serves
395
+ * is DECORATED (`_draft`, then `_diagnostics` from `decorateMetadataItem`),
396
+ * and the spec says a served body is not a valid input to the schema that
397
+ * produced it until those come off. {@link extractDraftBody} is the one
398
+ * reader for this method's answer — it takes the presence verdict and then
399
+ * the strip, in that order. That function used to be copy-pasted into four
400
+ * views and only one copy knew the rule (objectui#8181).
401
+ *
402
+ * Its tolerant siblings exist for the OTHER client method: `unwrapDraftBody`
403
+ * (app-shell) and `unwrapViewDraft` (this package) read a draft that arrived
404
+ * through {@link get}, which already unwrapped the envelope. They split on
405
+ * which method feeds them, not on what they mean — all three strip.
283
406
  */
284
407
  async getDraft(type, name, options = {}) {
285
408
  return this.readItemResponse(type, name, {
@@ -287,6 +410,31 @@ var MetadataClient = class _MetadataClient {
287
410
  ...options.packageId ? { packageId: options.packageId } : {}
288
411
  });
289
412
  }
413
+ /**
414
+ * Report the runtime authoring gate's advisory findings for a write the
415
+ * server has ALREADY committed (#4133 for the save door, #5026 for the
416
+ * publish door).
417
+ *
418
+ * Everything here is best-effort by construction, and that is the whole
419
+ * contract: the row is committed server-side before this runs, so neither a
420
+ * malformed body, nor a missing key, nor a throwing sink may change what the
421
+ * calling method returns or whether it throws. The server emits `advisories`
422
+ * ONLY when non-empty, so a clean write costs one absent-key check.
423
+ *
424
+ * One helper rather than a copy per door: every door's response declares the
425
+ * key identically, so a second inline copy could only ever drift. The batch
426
+ * door (`ce986aafc`) calls it once per `published[]` element, handing it
427
+ * the element — the object that carries the key there — and the same
428
+ * best-effort contract covers that loop unchanged.
429
+ */
430
+ emitAdvisories(body, event) {
431
+ if (!this.onSaveAdvisory) return;
432
+ try {
433
+ const advisories = readSaveAdvisories(body);
434
+ if (advisories.length > 0) this.onSaveAdvisory({ ...event, advisories });
435
+ } catch {
436
+ }
437
+ }
290
438
  /**
291
439
  * Save (PUT) a metadata item. The framework accepts both the bare
292
440
  * item payload and the `{ item: ... }` / `{ metadata: ... }`
@@ -299,6 +447,7 @@ var MetadataClient = class _MetadataClient {
299
447
  `MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
300
448
  );
301
449
  }
450
+ assertObjectMetadataWritable(type, item, "MetadataClient.save");
302
451
  const params = [];
303
452
  if (options.force) params.push("force=true");
304
453
  if (options.mode === "draft") params.push("mode=draft");
@@ -310,7 +459,6 @@ var MetadataClient = class _MetadataClient {
310
459
  "Content-Type": "application/json"
311
460
  };
312
461
  if (options.ifMatch) headers["If-Match"] = options.ifMatch;
313
- if (options.actor) headers["X-Actor"] = options.actor;
314
462
  const res = await this.fetchImpl(url, {
315
463
  method: "PUT",
316
464
  headers,
@@ -318,20 +466,12 @@ var MetadataClient = class _MetadataClient {
318
466
  });
319
467
  if (!res.ok) throw await parseError(res);
320
468
  const body = await res.json();
321
- if (this.onSaveAdvisory) {
322
- try {
323
- const advisories = readSaveAdvisories(body);
324
- if (advisories.length > 0) {
325
- this.onSaveAdvisory({
326
- type,
327
- name,
328
- mode: options.mode === "draft" ? "draft" : "publish",
329
- advisories
330
- });
331
- }
332
- } catch {
333
- }
334
- }
469
+ this.emitAdvisories(body, {
470
+ type,
471
+ name,
472
+ door: "save",
473
+ mode: options.mode === "draft" ? "draft" : "publish"
474
+ });
335
475
  return body;
336
476
  }
337
477
  /**
@@ -345,6 +485,61 @@ var MetadataClient = class _MetadataClient {
345
485
  * the rows and reports under `seedApplied` — a data problem never fails the
346
486
  * publish, so callers should check `seedApplied?.success` and warn the user
347
487
  * rather than assume the data went live.
488
+ *
489
+ * Same door as {@link publish} (`POST /meta/:type/:name/publish`), so it
490
+ * reports the gate's advisories the same way (#5026).
491
+ *
492
+ * The BATCH door (`POST /packages/:id/publish-drafts`) is a different route
493
+ * with a method of its own — {@link publishPackageDrafts} — and it reports
494
+ * too. It did not when this paragraph was first written: it discarded
495
+ * per-draft advisories server-side, which was objectstack#9343, and that card
496
+ * has since landed with a ruling that each `published[]` element carries
497
+ * them (`ce986aafc` is the client half).
498
+ *
499
+ * What survives that change is the READING RULE, which is about this method
500
+ * rather than about the other route: it reads the top level of the
501
+ * single-item body and nothing else. A batch-shaped body arriving HERE still
502
+ * reports nothing, because `PublishMetaItemResponse` declares no
503
+ * `published[]` and a client that went looking for findings wherever they
504
+ * might be would be inventing a contract instead of reading one.
505
+ *
506
+ * ## Why there is no `{ success, data }` unwrapping here (`7b433197d`)
507
+ *
508
+ * This method used to unwrap a dispatcher-shaped envelope before returning,
509
+ * while {@link publish} — the same route, a couple of hundred lines down —
510
+ * did not. Two spellings of one wire contract, and nothing said which was
511
+ * right. The tolerance is gone because the route was MEASURED, at the
512
+ * producer rather than inferred from the response schema:
513
+ *
514
+ * - The only mount of `POST /api/v1/meta/:type/:name/publish` is the REST
515
+ * server's own route-manager registration, which answers
516
+ * `res.json(await protocol.publishMetaItem(...))` — the protocol's object
517
+ * verbatim, with no envelope branch. The project-scoped mirror
518
+ * (`/api/v1/environments/:environmentId/...`) re-mounts that same handler.
519
+ * - The `{ success, data }` envelope has exactly one producer, the runtime
520
+ * `HttpDispatcher`, and it does not serve this route: its `/meta` domain
521
+ * has no publish branch, the dispatcher's own route ledger has no row for
522
+ * it, and a three-segment `/meta` path that is not `/published` terminates
523
+ * in a located `routeNotFound`. So no composition — REST server, the
524
+ * dispatcher-only Hono adapter, or both — puts an envelope on this body.
525
+ * - `packages/spec` states the same split in the two docstrings side by
526
+ * side: `PublishMetaItemResponseSchema` describes "the FULL body" this
527
+ * route returns and records that the REST route hands the producer's
528
+ * object to `res.json()` verbatim, while its batch sibling
529
+ * `PublishPackageDraftsResponseSchema` describes a body answered
530
+ * explicitly "inside the dispatcher's `{ success, data }` envelope".
531
+ *
532
+ * ⛔ So an enveloped body on THIS route is not a shape the platform emits,
533
+ * and unwrapping one is not a kindness: it is Commandment #0.1's lenient
534
+ * fallback, a second de-facto contract for a route that declares one. It
535
+ * also fails in the direction that hides the problem — a body that arrived
536
+ * enveloped is one this door did not actually serve, and unwrapping it
537
+ * would present that as a successful promotion.
538
+ *
539
+ * ⚠️ Sibling tolerances in this file are NOT this defect and must stay:
540
+ * {@link listDrafts} reads `data?.data?.drafts` because `GET /meta/_drafts`
541
+ * IS a dispatcher route, and `publishHealthFromResponse` unwraps because the
542
+ * batch door really is enveloped. The rule is per-route, not per-file.
348
543
  */
349
544
  async publishDraft(type, name) {
350
545
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
@@ -355,24 +550,122 @@ var MetadataClient = class _MetadataClient {
355
550
  });
356
551
  if (!res.ok) throw await parseError(res);
357
552
  const body = await res.json().catch(() => ({}));
358
- const inner = body?.data && typeof body.data === "object" ? body.data : body;
359
- return inner;
553
+ this.emitAdvisories(body, { type, name, door: "publish", mode: "publish" });
554
+ return body;
360
555
  }
361
556
  /**
362
- * Get the 3-state layered view of a metadata item (Phase 3a). Returns
363
- * `code` (the artifact / fallback default), `overlay` (the saved
364
- * customisation, if any), and `effective` (what the runtime sees).
557
+ * Publish EVERY pending draft bound to one package — Studio's "publish whole
558
+ * app" door, `POST /api/v1/packages/:id/publish-drafts` (ADR-0033; the route
559
+ * that orders structure-before-seeds server-side and runs the ADR-0038 L3
560
+ * runtime probes).
561
+ *
562
+ * ## Why it is expressed here at all (`ce986aafc`)
563
+ *
564
+ * It was not, and that was the defect. Two app-shell call sites fired this
565
+ * route with a bare `fetch` / a page-private `apiJson`, outside the seam that
566
+ * covers every other metadata write — so when objectstack#9343 landed and the
567
+ * server began sending per-draft advisories, the author publishing a whole
568
+ * app was told nothing, while the SAME button's client-side capability lint
569
+ * still raised a toast. A door that cannot report is not a door the gate can
570
+ * reach, however loudly the server speaks.
571
+ *
572
+ * ## What it reports, and what it refuses to invent
573
+ *
574
+ * `PublishPackageDraftsResponseSchema` declares `advisories` on EACH
575
+ * `published[]` element — objectstack#9343's ruled shape, the same element
576
+ * type and the same omitted-when-empty discipline as the single-item door,
577
+ * and explicitly not a parallel top-level map. So this method emits one
578
+ * {@link MetadataSaveAdvisoryEvent} per advised element, each naming that
579
+ * element's own `type` / `name`, through the same sink, event and renderer
580
+ * the save and single-item publish doors use.
581
+ *
582
+ * ⛔ It renders only what the server sent where the schema says it sits:
583
+ *
584
+ * - `advisories` is read off the element, by the one shared reader, which
585
+ * drops anything that is not a complete finding.
586
+ * - An element that does not carry the `type` and `name` the schema states as
587
+ * REQUIRED is skipped rather than reported under invented identity — the
588
+ * event names the item the author has to go fix, and an event that cannot
589
+ * name it truthfully is worse than silence.
590
+ * - Nothing is derived, counted or summarised from the batch: no advisory
591
+ * exists here that the server did not put in the body.
592
+ *
593
+ * ## The failure shapes stay the callers'
594
+ *
595
+ * Non-2xx throws {@link MetadataError}, like every other method. A 2xx is
596
+ * RETURNED unexamined — `success: false` is not a failure on this route
597
+ * (`outcome: 'nothing_to_publish'` answers it too, and the spec's own text
598
+ * says to read `outcome`, not the boolean), and the two callers have their
599
+ * own, different rules for the refusal and rolled-back cases. Deciding that
600
+ * here would change behaviour this card is not about.
601
+ */
602
+ async publishPackageDrafts(packageId) {
603
+ if (!packageId || !String(packageId).trim()) {
604
+ throw new Error(
605
+ "MetadataClient.publishPackageDrafts: packageId must be non-empty. The POST /packages/:id/publish-drafts route requires an id segment."
606
+ );
607
+ }
608
+ const url = `${packagesBaseOf(this.base)}/${encodeURIComponent(packageId)}/publish-drafts`;
609
+ const res = await this.fetchImpl(url, {
610
+ method: "POST",
611
+ // Both call sites this replaces sent the session cookie, and the client's
612
+ // own fetch adds the console's Bearer token — so the routed call carries a
613
+ // superset of what the bare doors carried, never less.
614
+ credentials: "include",
615
+ headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
616
+ body: "{}"
617
+ });
618
+ if (!res.ok) throw await parseError(res);
619
+ const body = unwrapDispatcherEnvelope(await res.json().catch(() => ({})));
620
+ for (const element of Array.isArray(body.published) ? body.published : []) {
621
+ if (!element || typeof element !== "object") continue;
622
+ const { type, name } = element;
623
+ if (typeof type !== "string" || typeof name !== "string") continue;
624
+ this.emitAdvisories(element, { type, name, door: "publish", mode: "publish" });
625
+ }
626
+ return body;
627
+ }
628
+ /**
629
+ * Get the 3-state layered view of a metadata item: `code` (the packaged
630
+ * artifact baseline), `overlay` (the tenant customisation row alone) and
631
+ * `effective` (the merged value the runtime sees).
632
+ *
633
+ * Reads **`GET /meta/:type/:name/layers`** — the path the framework declares
634
+ * for this projection, with a response schema of its own
635
+ * (`GetMetaItemLayeredResponseSchema`, objectstack#5882 ruling B). It used to
636
+ * be reached by hanging a `layers` flag on the ordinary item read, which made
637
+ * one route answer two unrelated representations while `packages/spec`
638
+ * declared only one of them. That spelling still answers this same body
639
+ * inside its deprecation window (the response carries RFC 9745
640
+ * `Deprecation: true` and an RFC 8288 `Link: rel="successor-version"` back to
641
+ * this path) and is scheduled for removal upstream, so nothing here may
642
+ * depend on it — the repo-wide ratchet is
643
+ * `scripts/__tests__/layered-read-declared-path-4016.test.ts`.
644
+ *
645
+ * The request is built here rather than delegated to `@objectstack/client`
646
+ * because the SDK expresses no layered read in EITHER spelling: the
647
+ * framework's REST route ledger records this route as `server-only`,
648
+ * "consumed by objectui over plain HTTP", and whether the SDK should express
649
+ * it is an open upstream product call.
650
+ *
651
+ * One behaviour delta rides along with the path, and it is the server's
652
+ * choice rather than ours: the retired flag FELL THROUGH to the plain item
653
+ * read on a backend whose protocol implementation had no layered support,
654
+ * answering the `{ type, name, item }` envelope. A dedicated path refuses to
655
+ * answer a different resource under this one's declared shape, so it returns
656
+ * 501 `NOT_IMPLEMENTED` instead — which surfaces here as a thrown error
657
+ * rather than a view with `code` and `overlay` silently blank.
365
658
  */
366
659
  async layered(type, name, options = {}) {
367
- const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : "";
368
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
660
+ const qs = options.packageId ? `?package=${encodeURIComponent(options.packageId)}` : "";
661
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/layers${qs}`;
369
662
  const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
370
663
  if (res.status === 404) {
371
664
  return { code: null, overlay: null, overlayScope: null, effective: null };
372
665
  }
373
666
  if (!res.ok) throw await parseError(res);
374
667
  const body = await res.json();
375
- const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
668
+ const hasEnvelope = typeof body === "object" && body !== null && ("code" in body || "overlay" in body || "effective" in body);
376
669
  if (!hasEnvelope) {
377
670
  return {
378
671
  code: null,
@@ -381,14 +674,34 @@ var MetadataClient = class _MetadataClient {
381
674
  effective: body
382
675
  };
383
676
  }
677
+ const parsed = import_api.GetMetaItemLayeredResponseSchema.safeParse(body);
678
+ if (parsed.success) {
679
+ const envelope = parsed.data;
680
+ return {
681
+ code: envelope.code ?? null,
682
+ overlay: envelope.overlay ?? null,
683
+ overlayScope: envelope.overlayScope,
684
+ effective: envelope.effective ?? null,
685
+ ...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
686
+ lock: envelope.lock,
687
+ ...envelope.lockReason !== void 0 ? { lockReason: envelope.lockReason } : {},
688
+ ...envelope.lockSource !== void 0 ? { lockSource: envelope.lockSource } : {},
689
+ ...envelope.lockDocsUrl !== void 0 ? { lockDocsUrl: envelope.lockDocsUrl } : {},
690
+ ...envelope.provenance !== void 0 ? { provenance: envelope.provenance } : {},
691
+ ...envelope.packageId !== void 0 ? { packageId: envelope.packageId } : {},
692
+ ...envelope.packageVersion !== void 0 ? { packageVersion: envelope.packageVersion } : {},
693
+ editable: envelope.editable,
694
+ deletable: envelope.deletable,
695
+ resettable: envelope.resettable
696
+ };
697
+ }
698
+ const unrecognized = unrecognizedEnvelopeKeys(body);
384
699
  return {
385
700
  code: body.code ?? null,
386
701
  overlay: body.overlay ?? null,
387
702
  overlayScope: body.overlayScope ?? null,
388
703
  effective: body.effective ?? null,
389
704
  ...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
390
- // ADR-0010 Phase 4 — pass through the metadata-protection envelope so
391
- // the editor can render lock affordances without a second round trip.
392
705
  ...body.lock !== void 0 ? { lock: body.lock } : {},
393
706
  ...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
394
707
  ...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
@@ -398,7 +711,8 @@ var MetadataClient = class _MetadataClient {
398
711
  ...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
399
712
  ...body.editable !== void 0 ? { editable: body.editable } : {},
400
713
  ...body.deletable !== void 0 ? { deletable: body.deletable } : {},
401
- ...body.resettable !== void 0 ? { resettable: body.resettable } : {}
714
+ ...body.resettable !== void 0 ? { resettable: body.resettable } : {},
715
+ ...unrecognized.length > 0 ? { _unrecognized: unrecognized } : {}
402
716
  };
403
717
  }
404
718
  /**
@@ -455,7 +769,6 @@ var MetadataClient = class _MetadataClient {
455
769
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
456
770
  const headers = { ...this.headers };
457
771
  if (options.ifMatch) headers["If-Match"] = options.ifMatch;
458
- if (options.actor) headers["X-Actor"] = options.actor;
459
772
  const res = await this.fetchImpl(url, { method: "DELETE", headers });
460
773
  if (!res.ok) throw await parseError(res);
461
774
  return await res.json();
@@ -466,21 +779,29 @@ var MetadataClient = class _MetadataClient {
466
779
  * `{ success, version, seq, message }`. Throws a `404 no_draft` if
467
780
  * nothing is pending and `409 metadata_conflict` if the published
468
781
  * overlay moved while the draft was sitting.
782
+ *
783
+ * Reports the runtime authoring gate's advisory findings through
784
+ * {@link MetadataClientConfig.onSaveAdvisory} when the response carries them
785
+ * (#5026 / objectstack#9176), with `door: 'publish'`. This is the door the
786
+ * Studio designer takes on every edit, so it is the one whose findings an
787
+ * author actually sees.
469
788
  */
470
789
  async publish(type, name, options = {}) {
471
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
790
+ const qs = options.packageId ? `?package=${encodeURIComponent(options.packageId)}` : "";
791
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish${qs}`;
472
792
  const headers = {
473
793
  ...this.headers,
474
794
  "Content-Type": "application/json"
475
795
  };
476
- if (options.actor) headers["X-Actor"] = options.actor;
477
796
  const res = await this.fetchImpl(url, {
478
797
  method: "POST",
479
798
  headers,
480
799
  body: JSON.stringify(options.message ? { message: options.message } : {})
481
800
  });
482
801
  if (!res.ok) throw await parseError(res);
483
- return await res.json();
802
+ const body = await res.json();
803
+ this.emitAdvisories(body, { type, name, door: "publish", mode: "publish" });
804
+ return body;
484
805
  }
485
806
  /**
486
807
  * Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
@@ -496,7 +817,6 @@ var MetadataClient = class _MetadataClient {
496
817
  ...this.headers,
497
818
  "Content-Type": "application/json"
498
819
  };
499
- if (options.actor) headers["X-Actor"] = options.actor;
500
820
  const res = await this.fetchImpl(url, {
501
821
  method: "POST",
502
822
  headers,
@@ -573,14 +893,32 @@ function errorCodeIsAnyOf(err, codes) {
573
893
 
574
894
  // ../core/src/utils/filter-converter.ts
575
895
  var import_ui = require("@objectstack/spec/ui");
896
+ var import_data2 = require("@objectstack/spec/data");
897
+
898
+ // ../core/src/utils/text-comparand.ts
899
+ var import_data = require("@objectstack/spec/data");
900
+
901
+ // ../core/src/utils/filter-converter.ts
576
902
  var FilterOperatorError = class extends Error {
577
- constructor(message) {
903
+ constructor(message, subject) {
578
904
  super(message);
579
905
  __publicField(this, "code", "INVALID_FILTER");
580
906
  __publicField(this, "httpStatus", 400);
907
+ /** @see FilterRefusalSubject */
908
+ __publicField(this, "operator");
909
+ /** @see FilterRefusalSubject */
910
+ __publicField(this, "field");
581
911
  this.name = "FilterOperatorError";
912
+ this.operator = subject?.operator;
913
+ this.field = subject?.field;
582
914
  }
583
915
  };
916
+ var RETIRED_OPERATOR_ALIASES = {
917
+ "$notin": "$nin",
918
+ "$notcontains": "$notContains",
919
+ "$startswith": "$startsWith",
920
+ "$endswith": "$endsWith"
921
+ };
584
922
  function convertOperatorToAST(operator) {
585
923
  const operatorMap = {
586
924
  "$eq": "=",
@@ -591,27 +929,148 @@ function convertOperatorToAST(operator) {
591
929
  "$lte": "<=",
592
930
  "$in": "in",
593
931
  "$nin": "nin",
594
- "$notin": "nin",
595
932
  "$between": "between",
596
933
  "$contains": "contains",
597
934
  "$notContains": "notcontains",
598
- "$notcontains": "notcontains",
599
935
  "$startsWith": "startswith",
600
- "$startswith": "startswith",
601
936
  "$endsWith": "endswith",
602
- "$endswith": "endswith"
937
+ // Case-insensitive contains. A canonical `FILTER_OPERATORS` member that
938
+ // `ValueDataSource` executes and `FilterConditionField` emits (for its
939
+ // `icontains` builder row — spelled `containsCaseInsensitive` until
940
+ // objectui#9306), while this map refused it with the
941
+ // generic unknown-operator paragraph — so ONE authored filter selected rows
942
+ // through the in-memory matcher and 400'd on the ObjectStack lowering path
943
+ // (objectui#8976). The other direction of the same split objectui#8568 fixed:
944
+ // there the map was MORE tolerant than the matcher, here it was LESS tolerant
945
+ // than the contract. Restored by aligning the map with the spec, per AGENTS.md
946
+ // #0 — not by removing the operator from the builder, which the spec declares.
947
+ //
948
+ // The VALUE is an IDENTITY, and that is not a typo: `icontains` is itself a
949
+ // member of the spec's `VALID_AST_OPERATORS`, so unlike its camelCase siblings
950
+ // above there is no case to squash. Same identity row, for the same stated
951
+ // reason, that `FILTER_OPERATOR_ALIASES` carries in
952
+ // `packages/data-objectstack/src/index.ts`.
953
+ "$icontains": "icontains"
603
954
  };
604
955
  return operatorMap[operator] || null;
605
956
  }
957
+ var AST_LOGIC_KEYWORD = {
958
+ $and: "and",
959
+ $or: "or"
960
+ };
961
+ function lowerLogicalGroup(field, keyword, value) {
962
+ if (!Array.isArray(value)) {
963
+ throw new FilterOperatorError(
964
+ `[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).`,
965
+ { operator: field }
966
+ );
967
+ }
968
+ if (value.length === 0) {
969
+ return keyword === "and" ? void 0 : falseIdentityLeaf(field, value);
970
+ }
971
+ const children = [];
972
+ let absorbed = false;
973
+ for (const child of value) {
974
+ if (child === null || typeof child !== "object" || Array.isArray(child)) {
975
+ throw new FilterOperatorError(
976
+ `[ObjectUI] Every member of '${field}' must be a filter condition OBJECT. Received ${JSON.stringify(child)}. Spec: FilterCondition declares '${field}?: FilterCondition[]' (data/filter.zod.ts).`,
977
+ { operator: field }
978
+ );
979
+ }
980
+ const lowered = convertFiltersToAST(child);
981
+ if (!Array.isArray(lowered)) {
982
+ if (keyword === "or") absorbed = true;
983
+ continue;
984
+ }
985
+ children.push(lowered);
986
+ }
987
+ if (absorbed) return void 0;
988
+ if (children.length === 0) return void 0;
989
+ return [keyword, ...children];
990
+ }
991
+ function falseIdentityLeaf(field, value) {
992
+ return [field, "=", value];
993
+ }
994
+ function isOperatorMap(value) {
995
+ const proto = Object.getPrototypeOf(value);
996
+ return proto === null || proto === Object.prototype || Object.getPrototypeOf(proto) === null;
997
+ }
998
+ function describeExoticComparand(value) {
999
+ let name;
1000
+ let text2;
1001
+ try {
1002
+ const proto = Object.getPrototypeOf(value);
1003
+ name = proto?.constructor?.name;
1004
+ } catch {
1005
+ name = void 0;
1006
+ }
1007
+ if (typeof name !== "string" || name === "") return "an object of an unrecognised type";
1008
+ try {
1009
+ text2 = String(value);
1010
+ } catch {
1011
+ text2 = void 0;
1012
+ }
1013
+ return text2 && text2 !== `[object ${name}]` ? `a ${name} instance (${text2})` : `a ${name} instance`;
1014
+ }
1015
+ function refuseTextComparand(field, operator, target) {
1016
+ const ported = `It is refused here rather than lowered onto the wire (objectui#9001; ported from ValueDataSource's refuseTextComparand, objectui#8748).`;
1017
+ throw new FilterOperatorError(
1018
+ `[ObjectUI] The ${(0, import_data.textComparandRefusalReason)(field, operator, target)}. ${ported}`,
1019
+ { operator, field }
1020
+ );
1021
+ }
1022
+ function refuseEmptyOperatorMap(field) {
1023
+ throw new FilterOperatorError(
1024
+ `[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.`,
1025
+ { field }
1026
+ );
1027
+ }
606
1028
  function convertFiltersToAST(filter) {
607
1029
  const conditions = [];
1030
+ let trueIdentityGroups = 0;
1031
+ let skippedNullKeys = 0;
608
1032
  for (const [field, value] of Object.entries(filter)) {
609
- if (value === null || value === void 0) continue;
1033
+ if (value === null || value === void 0) {
1034
+ skippedNullKeys += 1;
1035
+ continue;
1036
+ }
1037
+ const logicKeyword = AST_LOGIC_KEYWORD[field];
1038
+ if (logicKeyword) {
1039
+ const group = lowerLogicalGroup(field, logicKeyword, value);
1040
+ if (group !== void 0) conditions.push(group);
1041
+ else trueIdentityGroups += 1;
1042
+ continue;
1043
+ }
1044
+ if (field === "$not") {
1045
+ throw new FilterOperatorError(
1046
+ `[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)}.`,
1047
+ { operator: "$not" }
1048
+ );
1049
+ }
1050
+ if (Array.isArray(value)) {
1051
+ throw new FilterOperatorError(
1052
+ `[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] } }.`,
1053
+ { field }
1054
+ );
1055
+ }
610
1056
  if (typeof value === "object" && !Array.isArray(value)) {
611
- for (const [operator, operatorValue] of Object.entries(value)) {
1057
+ if ((0, import_data2.isAcceptedFilterComparand)(value)) {
1058
+ conditions.push([field, "=", value]);
1059
+ continue;
1060
+ }
1061
+ if (!isOperatorMap(value)) {
1062
+ throw new FilterOperatorError(
1063
+ `[ObjectUI] The filter on field '${field}' carries ${describeExoticComparand(value)} in comparand position. '@objectstack/spec' does not accept it: a comparison value must be ${import_data2.ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (ACCEPTED_FILTER_COMPARAND_TYPES), so isAcceptedFilterComparand is false for it and normalizeFilterComparandTypes answers 400 INVALID_FILTER \u2014 "which no driver can compare". It is not an operator map either: only a plain object can be one, so until objectui#8567 this value was read as ZERO operators and the '${field}' condition was dropped from the filter entirely, silently WIDENING the result set. Spell a text match as { ${field}: { $contains: '...' } } ($startsWith / $endsWith; a pattern itself has no operator here, see the $regex refusal), a membership test as { ${field}: { $in: [...] } }, and a date bound as { ${field}: { $gte: new Date(...) } }.`,
1064
+ { field }
1065
+ );
1066
+ }
1067
+ const operatorEntries = Object.entries(value);
1068
+ if (operatorEntries.length === 0) refuseEmptyOperatorMap(field);
1069
+ for (const [operator, operatorValue] of operatorEntries) {
612
1070
  if (operator === "$regex") {
613
1071
  throw new FilterOperatorError(
614
- `[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.`
1072
+ `[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.`,
1073
+ { operator: "$regex", field }
615
1074
  );
616
1075
  }
617
1076
  if (operator === "$null") {
@@ -622,12 +1081,33 @@ function convertFiltersToAST(filter) {
622
1081
  conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
623
1082
  continue;
624
1083
  }
1084
+ if (operator === "$empty") {
1085
+ if (operatorValue !== true && operatorValue !== false) {
1086
+ throw new FilterOperatorError(
1087
+ `[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.`,
1088
+ { operator: "$empty", field }
1089
+ );
1090
+ }
1091
+ conditions.push([field, operatorValue ? "is_empty" : "is_not_empty", true]);
1092
+ continue;
1093
+ }
625
1094
  const astOperator = convertOperatorToAST(operator);
626
1095
  if (astOperator) {
1096
+ if (astOperator === "icontains" && (0, import_data.isRefusedTextComparand)(operatorValue)) {
1097
+ refuseTextComparand(field, operator, operatorValue);
1098
+ }
627
1099
  conditions.push([field, astOperator, operatorValue]);
628
1100
  } else {
1101
+ const canonical = RETIRED_OPERATOR_ALIASES[operator];
1102
+ if (canonical) {
1103
+ throw new FilterOperatorError(
1104
+ `[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).`,
1105
+ { operator, field }
1106
+ );
1107
+ }
629
1108
  throw new FilterOperatorError(
630
- `[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.`
1109
+ `[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.`,
1110
+ { operator, field }
631
1111
  );
632
1112
  }
633
1113
  }
@@ -636,6 +1116,13 @@ function convertFiltersToAST(filter) {
636
1116
  }
637
1117
  }
638
1118
  if (conditions.length === 0) {
1119
+ const keysTheLoopProcessed = Object.keys(filter).length - skippedNullKeys;
1120
+ if (trueIdentityGroups > 0 && trueIdentityGroups === keysTheLoopProcessed) {
1121
+ return void 0;
1122
+ }
1123
+ if (skippedNullKeys > 0 && skippedNullKeys === Object.keys(filter).length) {
1124
+ return void 0;
1125
+ }
639
1126
  return filter;
640
1127
  }
641
1128
  if (conditions.length === 1) {
@@ -643,6 +1130,49 @@ function convertFiltersToAST(filter) {
643
1130
  }
644
1131
  return ["and", ...conditions];
645
1132
  }
1133
+ var ARRAY_VALUED_VIEW_OPERATORS = /* @__PURE__ */ new Set([
1134
+ ...import_ui.VIEW_FILTER_LIST_VALUE_OPERATORS,
1135
+ ...import_ui.VIEW_FILTER_PAIR_VALUE_OPERATORS
1136
+ ]);
1137
+ var KNOWN_VIEW_OPERATORS = new Set(import_ui.VIEW_FILTER_OPERATORS);
1138
+
1139
+ // ../core/src/utils/column-sortability.ts
1140
+ var import_api2 = require("@objectstack/spec/api");
1141
+ var OBJECT_SORTABILITY_KEY = /* @__PURE__ */ Symbol.for("objectui.objectSortability");
1142
+ function asFieldSortability(value) {
1143
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
1144
+ const sortable = value.sortable;
1145
+ if (typeof sortable !== "boolean") return void 0;
1146
+ const reason = value.reason;
1147
+ const caveat = value.caveat;
1148
+ return {
1149
+ sortable,
1150
+ ...typeof reason === "string" ? { reason } : {},
1151
+ ...typeof caveat === "string" ? { caveat } : {}
1152
+ };
1153
+ }
1154
+ function normalizeObjectSortability(served) {
1155
+ if (!served || typeof served !== "object" || Array.isArray(served)) return void 0;
1156
+ const rawFields = served.fields;
1157
+ if (!rawFields || typeof rawFields !== "object" || Array.isArray(rawFields)) return void 0;
1158
+ const fields = {};
1159
+ for (const [name, entry] of Object.entries(rawFields)) {
1160
+ const verdict = asFieldSortability(entry);
1161
+ if (verdict) fields[name] = verdict;
1162
+ }
1163
+ return { fields };
1164
+ }
1165
+ function attachObjectSortability(schema, served) {
1166
+ const projection = normalizeObjectSortability(served);
1167
+ if (!projection || !schema || typeof schema !== "object") return schema;
1168
+ Object.defineProperty(schema, OBJECT_SORTABILITY_KEY, {
1169
+ value: projection,
1170
+ enumerable: false,
1171
+ configurable: true,
1172
+ writable: true
1173
+ });
1174
+ return schema;
1175
+ }
646
1176
 
647
1177
  // ../core/src/adapters/batchTransaction.ts
648
1178
  function idOf(rec) {
@@ -711,20 +1241,143 @@ async function emulateBatchTransaction(dataSource, operations) {
711
1241
  }
712
1242
 
713
1243
  // ../core/src/utils/reference-keys.ts
714
- function normalizeFieldReferenceKeys(fieldDef) {
1244
+ var import_data3 = require("@objectstack/spec/data");
1245
+ var aliasProbe = (key) => key.toLowerCase().replace(/[_\-\s]/g, "");
1246
+ var declaredFieldKeys = null;
1247
+ var probeFolds = null;
1248
+ var collidingProbes = null;
1249
+ function fieldKeyFolds() {
1250
+ if (probeFolds) return probeFolds;
1251
+ const declared = Object.keys(import_data3.FieldSchema.shape);
1252
+ const seen = /* @__PURE__ */ new Map();
1253
+ for (const key of declared) {
1254
+ const probe = aliasProbe(key);
1255
+ seen.set(probe, [...seen.get(probe) ?? [], key]);
1256
+ }
1257
+ const folds = /* @__PURE__ */ new Map();
1258
+ const collisions = /* @__PURE__ */ new Map();
1259
+ for (const [probe, keys] of seen) {
1260
+ if (keys.length === 1) folds.set(probe, keys[0]);
1261
+ else collisions.set(probe, keys);
1262
+ }
1263
+ declaredFieldKeys = new Set(declared);
1264
+ collidingProbes = collisions;
1265
+ probeFolds = folds;
1266
+ return folds;
1267
+ }
1268
+ var warnedRetiredSpelling = /* @__PURE__ */ new Set();
1269
+ var warnedUnfoldableSpelling = /* @__PURE__ */ new Set();
1270
+ function warnUnfoldableRetiredKey(reason, key, detail, fieldName, objectName, f) {
1271
+ if (!isDev()) return;
1272
+ const named = fieldName ?? (typeof f.name === "string" ? f.name : "(unnamed field)");
1273
+ const owner = objectName ?? "(unknown object)";
1274
+ const memo = `${owner}:${named}:${key}:${reason}`;
1275
+ if (warnedUnfoldableSpelling.has(memo)) return;
1276
+ const published = reason === "no-declared-twin" && key === "id_field" ? ` \`@objectstack/spec\`'s \`FIELD_KEY_GUIDANCE\` gives the reason: ${import_data3.FIELD_KEY_GUIDANCE.id_field.why}` : "";
1277
+ warnedUnfoldableSpelling.add(memo);
1278
+ console.warn(
1279
+ `[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)`
1280
+ );
1281
+ }
1282
+ function canonicalizeRetiredFieldKeys(f, fieldName, objectName) {
1283
+ const folds = fieldKeyFolds();
1284
+ for (const key of Object.keys(f)) {
1285
+ if (declaredFieldKeys.has(key)) continue;
1286
+ const value = f[key];
1287
+ if (value === void 0) continue;
1288
+ if (REFERENCE_ARM_KEYS.has(key)) continue;
1289
+ const probe = aliasProbe(key);
1290
+ const canonical = folds.get(probe);
1291
+ if (canonical === void 0) {
1292
+ const colliding = collidingProbes.get(probe);
1293
+ if (colliding) {
1294
+ warnUnfoldableRetiredKey(
1295
+ "ambiguous-probe",
1296
+ key,
1297
+ `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.`,
1298
+ fieldName,
1299
+ objectName,
1300
+ f
1301
+ );
1302
+ } else {
1303
+ warnUnfoldableRetiredKey(
1304
+ "no-declared-twin",
1305
+ key,
1306
+ `\`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.`,
1307
+ fieldName,
1308
+ objectName,
1309
+ f
1310
+ );
1311
+ }
1312
+ continue;
1313
+ }
1314
+ if (f[canonical] !== void 0) {
1315
+ if (f[canonical] !== value) {
1316
+ warnUnfoldableRetiredKey(
1317
+ "canonical-occupied",
1318
+ key,
1319
+ `\`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.`,
1320
+ fieldName,
1321
+ objectName,
1322
+ f
1323
+ );
1324
+ }
1325
+ continue;
1326
+ }
1327
+ f[canonical] = value;
1328
+ if (!isDev()) continue;
1329
+ const named = fieldName ?? (typeof f.name === "string" ? f.name : "(unnamed field)");
1330
+ const owner = objectName ?? "(unknown object)";
1331
+ const memo = `${owner}:${named}:${key}:${canonical}`;
1332
+ if (warnedRetiredSpelling.has(memo)) continue;
1333
+ warnedRetiredSpelling.add(memo);
1334
+ console.warn(
1335
+ `[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)`
1336
+ );
1337
+ }
1338
+ }
1339
+ var warnedLegacyOnly = /* @__PURE__ */ new Set();
1340
+ var isDev = () => globalThis.process?.env?.NODE_ENV !== "production";
1341
+ var LEGACY_REFERENCE_KEYS = ["reference_to", "referenceTo"];
1342
+ var REFERENCE_ARM_KEYS = new Set(LEGACY_REFERENCE_KEYS);
1343
+ function warnOnLegacyOnlyReference(f, fieldName, objectName) {
1344
+ if (!isDev()) return;
1345
+ if (f.reference !== void 0) return;
1346
+ const spelled = LEGACY_REFERENCE_KEYS.filter((k) => f[k] !== void 0 && f[k] !== "");
1347
+ if (spelled.length === 0) return;
1348
+ const named = fieldName ?? (typeof f.name === "string" ? f.name : "(unnamed field)");
1349
+ const owner = objectName ?? "(unknown object)";
1350
+ for (const key of spelled) {
1351
+ const memo = `${owner}:${named}:${key}:${String(f[key])}`;
1352
+ if (warnedLegacyOnly.has(memo)) continue;
1353
+ warnedLegacyOnly.add(memo);
1354
+ console.warn(
1355
+ `[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)`
1356
+ );
1357
+ }
1358
+ }
1359
+ function normalizeFieldReferenceKeys(fieldDef, fieldName, objectName) {
715
1360
  if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
716
1361
  const f = fieldDef;
717
- const target = f.reference_to ?? f.reference ?? f.referenceTo;
718
- if (target == null || target === "") return fieldDef;
719
- if (f.reference_to === void 0) f.reference_to = target;
720
- if (f.reference === void 0) f.reference = target;
1362
+ canonicalizeRetiredFieldKeys(f, fieldName, objectName);
1363
+ if (f.reference !== void 0) return fieldDef;
1364
+ const legacy = f.reference_to ?? f.referenceTo;
1365
+ if (legacy == null || legacy === "") return fieldDef;
1366
+ warnOnLegacyOnlyReference(f, fieldName, objectName);
1367
+ f.reference = legacy;
721
1368
  return fieldDef;
722
1369
  }
723
1370
  function normalizeSchemaReferenceKeys(schema) {
724
1371
  const fields = schema && typeof schema === "object" ? schema.fields : null;
725
1372
  if (!fields || typeof fields !== "object") return schema;
726
- const defs = Array.isArray(fields) ? fields : Object.values(fields);
727
- for (const def of defs) normalizeFieldReferenceKeys(def);
1373
+ const objectName = typeof schema.name === "string" ? schema.name : void 0;
1374
+ if (Array.isArray(fields)) {
1375
+ for (const def of fields) normalizeFieldReferenceKeys(def, void 0, objectName);
1376
+ } else {
1377
+ for (const [name, def] of Object.entries(fields)) {
1378
+ normalizeFieldReferenceKeys(def, name, objectName);
1379
+ }
1380
+ }
728
1381
  return schema;
729
1382
  }
730
1383
 
@@ -1097,50 +1750,23 @@ function isErrorType(error, errorClass) {
1097
1750
  return error instanceof errorClass;
1098
1751
  }
1099
1752
 
1100
- // src/contracts.ts
1101
- function validatePluginContract(contract) {
1102
- const errors = [];
1103
- const warnings = [];
1104
- if (!contract.name || contract.name.trim().length === 0) {
1105
- errors.push({ field: "name", message: "Plugin name is required", code: "MISSING_NAME" });
1106
- }
1107
- if (!contract.version || !/^\d+\.\d+\.\d+/.test(contract.version)) {
1108
- errors.push({ field: "version", message: "Valid semver version is required", code: "INVALID_VERSION" });
1109
- }
1110
- if (!contract.exports || contract.exports.length === 0) {
1111
- errors.push({ field: "exports", message: "At least one export is required", code: "NO_EXPORTS" });
1112
- }
1113
- if (contract.exports) {
1114
- const validTypes = ["component", "hook", "utility", "provider"];
1115
- for (const exp of contract.exports) {
1116
- if (!exp.name) {
1117
- errors.push({ field: "exports.name", message: "Export name is required", code: "MISSING_EXPORT_NAME" });
1118
- }
1119
- if (!validTypes.includes(exp.type)) {
1120
- errors.push({ field: "exports.type", message: `Invalid export type: ${exp.type}`, code: "INVALID_EXPORT_TYPE" });
1121
- }
1122
- }
1123
- }
1124
- if (!contract.permissions || contract.permissions.length === 0) {
1125
- warnings.push("No permissions declared \u2014 plugin will have minimal access");
1126
- }
1127
- return { valid: errors.length === 0, errors, warnings };
1753
+ // src/error-envelope.ts
1754
+ function readErrorEnvelope(body) {
1755
+ if (!body || typeof body !== "object") return {};
1756
+ const env = body;
1757
+ const nested = env.error && typeof env.error === "object" ? env.error : void 0;
1758
+ const reading = {};
1759
+ const message = text(nested?.message) ?? text(env.message) ?? text(env.error);
1760
+ if (message !== void 0) reading.message = message;
1761
+ const code = present(env.code) ?? present(nested?.code);
1762
+ if (code !== void 0) reading.code = code;
1763
+ return reading;
1128
1764
  }
1129
- function generateContractManifest(contract) {
1130
- return {
1131
- $schema: "https://objectui.org/schemas/plugin-contract-v1.json",
1132
- name: contract.name,
1133
- version: contract.version,
1134
- peerDependencies: contract.peerDependencies ?? {},
1135
- exports: contract.exports.map((exp) => ({
1136
- name: exp.name,
1137
- type: exp.type,
1138
- description: exp.description ?? ""
1139
- })),
1140
- permissions: contract.permissions ?? [],
1141
- api: contract.api ?? {},
1142
- generatedAt: (/* @__PURE__ */ new Date()).toISOString()
1143
- };
1765
+ function text(value) {
1766
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
1767
+ }
1768
+ function present(value) {
1769
+ return typeof value === "string" ? value : void 0;
1144
1770
  }
1145
1771
 
1146
1772
  // src/userState.ts
@@ -1245,265 +1871,31 @@ function createObjectStackUserStateAdapter(options) {
1245
1871
  };
1246
1872
  }
1247
1873
 
1248
- // src/integration.ts
1249
- var IntegrationManager = class {
1250
- constructor() {
1251
- __publicField(this, "integrations", /* @__PURE__ */ new Map());
1252
- }
1253
- /**
1254
- * Register a new integration.
1255
- */
1256
- register(id, config) {
1257
- this.integrations.set(id, config);
1258
- }
1259
- /**
1260
- * Remove an integration.
1261
- */
1262
- unregister(id) {
1263
- this.integrations.delete(id);
1264
- }
1265
- /**
1266
- * Get all registered integrations.
1267
- */
1268
- getAll() {
1269
- return new Map(this.integrations);
1270
- }
1271
- /**
1272
- * Get integrations that match a specific event.
1273
- */
1274
- getForEvent(event) {
1275
- return Array.from(this.integrations.values()).filter(
1276
- (integration) => integration.enabled && integration.triggers?.some((t) => t.event === event)
1277
- );
1278
- }
1279
- /**
1280
- * Dispatch an event to all matching integrations.
1281
- * Returns results for each integration.
1282
- */
1283
- async dispatch(event, payload) {
1284
- const matching = this.getForEvent(event);
1285
- const results = [];
1286
- for (const [id, integration] of this.integrations) {
1287
- if (!matching.includes(integration)) continue;
1288
- try {
1289
- await this.send(integration, payload);
1290
- results.push({ id, success: true });
1291
- } catch (err) {
1292
- results.push({ id, success: false, error: err.message });
1293
- }
1294
- }
1295
- return results;
1296
- }
1297
- /**
1298
- * Send payload to a specific integration.
1299
- */
1300
- async send(integration, payload) {
1301
- switch (integration.provider) {
1302
- case "webhook": {
1303
- const cfg = integration.config;
1304
- const url = cfg.url;
1305
- try {
1306
- const parsed = new URL(url);
1307
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1308
- throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
1309
- }
1310
- } catch (e) {
1311
- if (e instanceof TypeError) {
1312
- throw new Error(`Invalid webhook URL: ${url}`);
1313
- }
1314
- throw e;
1315
- }
1316
- await fetch(url, {
1317
- method: cfg.method,
1318
- headers: {
1319
- "Content-Type": "application/json",
1320
- ...cfg.headers
1321
- },
1322
- body: JSON.stringify(payload)
1323
- });
1324
- break;
1325
- }
1326
- case "slack": {
1327
- const cfg = integration.config;
1328
- const url = cfg.webhookUrl;
1329
- try {
1330
- const parsed = new URL(url);
1331
- if (parsed.protocol !== "https:") {
1332
- throw new Error(`Invalid Slack webhook URL protocol: ${parsed.protocol}`);
1333
- }
1334
- } catch (e) {
1335
- if (e instanceof TypeError) {
1336
- throw new Error(`Invalid Slack webhook URL: ${url}`);
1337
- }
1338
- throw e;
1339
- }
1340
- await fetch(url, {
1341
- method: "POST",
1342
- headers: { "Content-Type": "application/json" },
1343
- body: JSON.stringify({
1344
- channel: cfg.channel,
1345
- username: cfg.username,
1346
- icon_emoji: cfg.iconEmoji,
1347
- text: JSON.stringify(payload)
1348
- })
1349
- });
1350
- break;
1351
- }
1352
- // Email and other providers would require server-side implementation
1353
- default:
1354
- break;
1355
- }
1356
- }
1357
- };
1358
-
1359
- // src/security.ts
1360
- var SecurityManager = class {
1361
- constructor(policy = {}) {
1362
- __publicField(this, "policy");
1363
- __publicField(this, "auditLog", []);
1364
- this.policy = policy;
1365
- }
1366
- /**
1367
- * Generate a CSP header string from the configuration.
1368
- */
1369
- generateCSPHeader() {
1370
- const csp = this.policy.csp;
1371
- if (!csp) return "";
1372
- const directives = [];
1373
- if (csp.scriptSrc?.length) directives.push(`script-src ${csp.scriptSrc.join(" ")}`);
1374
- if (csp.styleSrc?.length) directives.push(`style-src ${csp.styleSrc.join(" ")}`);
1375
- if (csp.imgSrc?.length) directives.push(`img-src ${csp.imgSrc.join(" ")}`);
1376
- if (csp.connectSrc?.length) directives.push(`connect-src ${csp.connectSrc.join(" ")}`);
1377
- if (csp.fontSrc?.length) directives.push(`font-src ${csp.fontSrc.join(" ")}`);
1378
- if (csp.frameSrc?.length) directives.push(`frame-src ${csp.frameSrc.join(" ")}`);
1379
- if (csp.reportUri) directives.push(`report-uri ${csp.reportUri}`);
1380
- return directives.join("; ");
1381
- }
1382
- /**
1383
- * Record an audit log entry.
1384
- */
1385
- recordAudit(entry) {
1386
- if (!this.policy.auditLog?.enabled) return;
1387
- if (this.policy.auditLog.events && !this.policy.auditLog.events.includes(entry.event)) return;
1388
- const fullEntry = {
1389
- ...entry,
1390
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1391
- };
1392
- this.auditLog.push(fullEntry);
1393
- const dest = this.policy.auditLog.destination ?? "console";
1394
- if (dest === "console" || dest === "both") {
1395
- console.info("[AUDIT]", fullEntry.event, fullEntry.userId, fullEntry.resource ?? "");
1396
- }
1397
- }
1398
- /**
1399
- * Get audit log entries.
1400
- */
1401
- getAuditLog(filter) {
1402
- let entries = [...this.auditLog];
1403
- if (filter?.event) entries = entries.filter((e) => e.event === filter.event);
1404
- if (filter?.userId) entries = entries.filter((e) => e.userId === filter.userId);
1405
- if (filter?.since) entries = entries.filter((e) => e.timestamp >= filter.since);
1406
- return entries;
1407
- }
1408
- /**
1409
- * Apply data masking to a record.
1410
- */
1411
- maskRecord(record, userRoles = []) {
1412
- if (!this.policy.dataMasking?.rules?.length) return record;
1413
- const masked = { ...record };
1414
- const maskChar = this.policy.dataMasking.maskChar ?? "*";
1415
- for (const rule of this.policy.dataMasking.rules) {
1416
- if (!(rule.field in masked) || masked[rule.field] == null) continue;
1417
- if (rule.exemptRoles?.some((role) => userRoles.includes(role))) continue;
1418
- const value = String(masked[rule.field]);
1419
- switch (rule.strategy) {
1420
- case "full":
1421
- masked[rule.field] = maskChar.repeat(value.length);
1422
- break;
1423
- case "partial": {
1424
- const visible = rule.visibleChars ?? 4;
1425
- if (value.length <= visible) {
1426
- masked[rule.field] = maskChar.repeat(value.length);
1427
- } else {
1428
- masked[rule.field] = value.slice(0, visible) + maskChar.repeat(value.length - visible);
1429
- }
1430
- break;
1431
- }
1432
- case "hash":
1433
- masked[rule.field] = `[HASHED:${simpleHash(value)}]`;
1434
- break;
1435
- case "redact":
1436
- masked[rule.field] = "[REDACTED]";
1437
- break;
1438
- }
1439
- }
1440
- return masked;
1441
- }
1442
- /**
1443
- * Update the security policy.
1444
- */
1445
- updatePolicy(policy) {
1446
- this.policy = { ...this.policy, ...policy };
1447
- }
1448
- /**
1449
- * Get current security policy.
1450
- */
1451
- getPolicy() {
1452
- return { ...this.policy };
1453
- }
1454
- };
1455
- function simpleHash(str) {
1456
- let hash = 0;
1457
- for (let i = 0; i < str.length; i++) {
1458
- const char = str.charCodeAt(i);
1459
- hash = (hash << 5) - hash + char;
1460
- hash = hash & hash;
1461
- }
1462
- return Math.abs(hash).toString(36);
1874
+ // src/draft-envelope.ts
1875
+ var import_kernel = require("@objectstack/spec/kernel");
1876
+ function extractDraftBody(resp) {
1877
+ if (!resp || typeof resp !== "object") return null;
1878
+ const env = resp;
1879
+ if (!("item" in env)) return null;
1880
+ const body = env.item;
1881
+ if (!body || typeof body !== "object") return null;
1882
+ if (Object.keys(body).length === 0) return null;
1883
+ return (0, import_kernel.stripReadDecorations)(body);
1463
1884
  }
1464
1885
 
1465
- // src/studio.ts
1466
- function createDefaultCanvasConfig(overrides) {
1467
- return {
1468
- width: 1200,
1469
- height: 800,
1470
- background: "grid",
1471
- gridSize: 8,
1472
- snapToGrid: true,
1473
- zoom: {
1474
- min: 0.25,
1475
- max: 3,
1476
- step: 0.1,
1477
- current: 1
1478
- },
1479
- panOffset: { x: 0, y: 0 },
1480
- showMinimap: false,
1481
- minimapPosition: "bottom-right",
1482
- ...overrides
1483
- };
1886
+ // src/metadata-error.ts
1887
+ function extractIssues(e) {
1888
+ const issues = e?.issues;
1889
+ return Array.isArray(issues) ? issues : [];
1484
1890
  }
1485
- function snapToGrid(x, y, gridSize) {
1486
- return {
1487
- x: Math.round(x / gridSize) * gridSize,
1488
- y: Math.round(y / gridSize) * gridSize
1489
- };
1891
+ function formatMetadataIssue(i) {
1892
+ return `\u2022 ${i.path && i.path.length > 0 ? i.path : "(root)"} \u2014 ${i.message}`;
1490
1893
  }
1491
- function calculateAutoLayout(items, canvasWidth, padding = 40, gap = 40) {
1492
- const positions = [];
1493
- let currentX = padding;
1494
- let currentY = padding;
1495
- let rowMaxHeight = 0;
1496
- for (const item of items) {
1497
- if (currentX + item.width + padding > canvasWidth) {
1498
- currentX = padding;
1499
- currentY += rowMaxHeight + gap;
1500
- rowMaxHeight = 0;
1501
- }
1502
- positions.push({ id: item.id, x: currentX, y: currentY });
1503
- currentX += item.width + gap;
1504
- rowMaxHeight = Math.max(rowMaxHeight, item.height);
1505
- }
1506
- return positions;
1894
+ function formatMetadataError(e) {
1895
+ const issues = extractIssues(e);
1896
+ if (issues.length > 0) return issues.map(formatMetadataIssue).join("\n");
1897
+ const err = e;
1898
+ return err?.message ?? String(e);
1507
1899
  }
1508
1900
 
1509
1901
  // src/index.ts
@@ -1533,6 +1925,15 @@ var FILTER_OPERATOR_ALIASES = {
1533
1925
  notin: "nin",
1534
1926
  nin: "nin",
1535
1927
  contains: "contains",
1928
+ // Case-insensitive contains. A canonical `VIEW_FILTER_OPERATORS` member that
1929
+ // arrived with `@objectstack/spec` 17.1.0 (objectui#5328), and an IDENTITY row
1930
+ // like `contains` above: `icontains` is itself a member of
1931
+ // `VALID_AST_OPERATORS`, so the spelling the author writes is the spelling the
1932
+ // AST takes — nothing is translated and no case-sensitivity is lost.
1933
+ // Declared rather than left to the `?? op` fall-through on the rule this
1934
+ // table's test states: the AST gate accepting a spelling is not the driver
1935
+ // compiling it into a WHERE clause.
1936
+ icontains: "icontains",
1536
1937
  not_contains: "notcontains",
1537
1938
  notcontains: "notcontains",
1538
1939
  starts_with: "startswith",
@@ -1554,14 +1955,21 @@ var FILTER_OPERATOR_ALIASES = {
1554
1955
  before: "<",
1555
1956
  after: ">"
1556
1957
  };
1557
- function normalizeFilterOperator2(op) {
1958
+ function toAstFilterOperator(op) {
1558
1959
  if (typeof op !== "string") return null;
1559
1960
  const lower = op.toLowerCase();
1560
1961
  return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
1561
1962
  }
1963
+ function describeFilterEntry(entry) {
1964
+ try {
1965
+ return JSON.stringify(entry, (_key, value) => typeof value === "bigint" ? `${value}n` : value) ?? String(entry);
1966
+ } catch {
1967
+ return String(entry);
1968
+ }
1969
+ }
1562
1970
  var MalformedFilterError = class extends Error {
1563
1971
  constructor(entry, index) {
1564
- const shown = JSON.stringify(entry) ?? String(entry);
1972
+ const shown = describeFilterEntry(entry);
1565
1973
  super(
1566
1974
  `Filter entry ${index} is not a usable filter rule (${shown}). Expected { field, operator, value } with a non-empty field.`
1567
1975
  );
@@ -1579,12 +1987,104 @@ function isMalformedFilterError(error) {
1579
1987
  const e = error;
1580
1988
  return e.code === "INVALID_FILTER" || e.name === "MalformedFilterError";
1581
1989
  }
1582
- function objectFilterEntryToAST(entry) {
1990
+ var UnloweredAggregateWhereError = class extends Error {
1991
+ constructor(where, resource) {
1992
+ const shown = JSON.stringify(where) ?? String(where);
1993
+ super(
1994
+ `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.`
1995
+ );
1996
+ __publicField(this, "code", "INVALID_FILTER");
1997
+ __publicField(this, "httpStatus", 400);
1998
+ /** The value as received, so a caller can log what its producer actually built. */
1999
+ __publicField(this, "where");
2000
+ /** The object `aggregate()` was called for. */
2001
+ __publicField(this, "resource");
2002
+ this.name = "UnloweredAggregateWhereError";
2003
+ this.where = where;
2004
+ this.resource = resource;
2005
+ }
2006
+ };
2007
+ function assertSpecShapeWhereIsFilterAst(where, resource) {
2008
+ if (!Array.isArray(where)) return;
2009
+ if (where.length === 0) return;
2010
+ if ((0, import_data5.isFilterAST)(where)) return;
2011
+ throw new UnloweredAggregateWhereError(where, resource);
2012
+ }
2013
+ var ANALYTICS_ONLY_AGGREGATE_KEYS = ["filter", "field", "function"];
2014
+ var ANALYTICS_ONLY_KEY_ADVICE = {
2015
+ 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.",
2016
+ 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' }].",
2017
+ function: "`function` is the other half of that measure pair and belongs inside an `aggregations` node, not beside `groupBy`."
2018
+ };
2019
+ function specShapeSelectorReasons(params) {
2020
+ const reasons = [];
2021
+ if (Array.isArray(params?.groupBy)) reasons.push("`groupBy` is an array");
2022
+ if (Array.isArray(params?.aggregations)) reasons.push("`aggregations` is an array");
2023
+ if (params?.where !== void 0) reasons.push("a `where` key is present");
2024
+ return reasons;
2025
+ }
2026
+ var AnalyticsKeysOnSpecShapeError = class extends Error {
2027
+ constructor(keys, params, resource) {
2028
+ const received = {};
2029
+ for (const key of keys) received[key] = params?.[key];
2030
+ const named = keys.map((k) => `\`${k}\``).join(", ");
2031
+ const shown = keys.map((k) => `${k}=${JSON.stringify(params?.[k]) ?? String(params?.[k])}`).join(", ");
2032
+ const why = specShapeSelectorReasons(params).join(" and ");
2033
+ 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." : "";
2034
+ super(
2035
+ `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."
2036
+ );
2037
+ __publicField(this, "code", "INVALID_FILTER");
2038
+ __publicField(this, "httpStatus", 400);
2039
+ /** The offending keys, in the order {@link ANALYTICS_ONLY_AGGREGATE_KEYS} lists them. */
2040
+ __publicField(this, "keys");
2041
+ /** What each offending key carried, so a producer is identifiable from a log. */
2042
+ __publicField(this, "received");
2043
+ /** The object `aggregate()` was called for. */
2044
+ __publicField(this, "resource");
2045
+ this.name = "AnalyticsKeysOnSpecShapeError";
2046
+ this.keys = keys;
2047
+ this.received = received;
2048
+ this.resource = resource;
2049
+ }
2050
+ };
2051
+ function assertNoAnalyticsKeysOnSpecShape(params, resource) {
2052
+ const present2 = ANALYTICS_ONLY_AGGREGATE_KEYS.filter((key) => params?.[key] != null);
2053
+ if (present2.length === 0) return;
2054
+ throw new AnalyticsKeysOnSpecShapeError(present2, params, resource);
2055
+ }
2056
+ var UnlowerableAnalyticsFilterError = class extends Error {
2057
+ constructor(filter, resource, detail) {
2058
+ const shown = JSON.stringify(filter) ?? String(filter);
2059
+ super(
2060
+ `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."
2061
+ );
2062
+ __publicField(this, "code", "INVALID_FILTER");
2063
+ __publicField(this, "httpStatus", 400);
2064
+ /** The filter as received, so a caller can log what its producer actually built. */
2065
+ __publicField(this, "filter");
2066
+ /** The object `aggregate()` was called for. */
2067
+ __publicField(this, "resource");
2068
+ this.name = "UnlowerableAnalyticsFilterError";
2069
+ this.filter = filter;
2070
+ this.resource = resource;
2071
+ }
2072
+ };
2073
+ function refuseTextComparandEntry(entry, index, field, arrived) {
2074
+ 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).`;
2075
+ const error = new MalformedFilterError(entry, index);
2076
+ error.message = `Filter entry ${index} is refused. The ${(0, import_data6.textComparandRefusalReason)(field, arrived, entry.value)}. ${tail}`;
2077
+ throw error;
2078
+ }
2079
+ function objectFilterEntryToAST(entry, index) {
1583
2080
  if (!entry || typeof entry !== "object") return null;
1584
2081
  const field = entry.field;
1585
2082
  const rawOp = entry.operator ?? entry.op ?? "=";
1586
- const op = normalizeFilterOperator2(rawOp);
2083
+ const op = toAstFilterOperator(rawOp);
1587
2084
  if (!field || !op) return null;
2085
+ if (op === "icontains" && (0, import_data6.isRefusedTextComparand)(entry.value)) {
2086
+ refuseTextComparandEntry(entry, index, String(field), rawOp);
2087
+ }
1588
2088
  return [String(field), op, entry.value];
1589
2089
  }
1590
2090
  function isObjectFilterEntryForm(filter) {
@@ -1593,8 +2093,8 @@ function isObjectFilterEntryForm(filter) {
1593
2093
  }
1594
2094
  function objectFilterEntriesToAST(entries) {
1595
2095
  const nodes = entries.map((entry, i) => {
1596
- if (Array.isArray(entry)) return translateFilterChild(entry);
1597
- const tuple = objectFilterEntryToAST(entry);
2096
+ if (Array.isArray(entry)) return translateFilterChild(entry, i);
2097
+ const tuple = objectFilterEntryToAST(entry, i);
1598
2098
  if (!tuple) throw new MalformedFilterError(entry, i);
1599
2099
  return tuple;
1600
2100
  });
@@ -1605,15 +2105,17 @@ function translateFilterArray(filter) {
1605
2105
  if (isObjectFilterEntryForm(filter)) return objectFilterEntriesToAST(filter);
1606
2106
  const head = filter[0];
1607
2107
  if (typeof head === "string" && LOGICAL_AST_HEADS.has(head.toLowerCase())) {
1608
- return [head, ...filter.slice(1).map(translateFilterChild)];
2108
+ return [head, ...filter.slice(1).map((child, i) => translateFilterChild(child, i + 1))];
2109
+ }
2110
+ if (filter.every((child) => Array.isArray(child))) {
2111
+ return filter.map((child, i) => translateFilterChild(child, i));
1609
2112
  }
1610
- if (filter.every((child) => Array.isArray(child))) return filter.map(translateFilterChild);
1611
2113
  return filter;
1612
2114
  }
1613
- function translateFilterChild(child) {
2115
+ function translateFilterChild(child, index) {
1614
2116
  if (Array.isArray(child)) return child.length > 0 ? translateFilterArray(child) : child;
1615
2117
  if (child && typeof child === "object" && child.field !== void 0) {
1616
- const tuple = objectFilterEntryToAST(child);
2118
+ const tuple = objectFilterEntryToAST(child, index);
1617
2119
  if (tuple) return tuple;
1618
2120
  }
1619
2121
  return child;
@@ -1630,6 +2132,30 @@ function translateFilterToAST(filter) {
1630
2132
  }
1631
2133
  return void 0;
1632
2134
  }
2135
+ function lowerAnalyticsFilterForWire(filter, resource) {
2136
+ if (!Array.isArray(filter)) return filter;
2137
+ if (filter.length === 0) return void 0;
2138
+ const ast = translateFilterArray(filter);
2139
+ if (!(0, import_data5.isFilterAST)(ast)) throw new UnlowerableAnalyticsFilterError(filter, resource);
2140
+ let lowered;
2141
+ try {
2142
+ lowered = (0, import_data5.parseFilterAST)(ast);
2143
+ } catch (e) {
2144
+ throw new UnlowerableAnalyticsFilterError(
2145
+ filter,
2146
+ resource,
2147
+ e instanceof Error ? e.message : String(e)
2148
+ );
2149
+ }
2150
+ if (lowered === void 0) {
2151
+ throw new UnlowerableAnalyticsFilterError(
2152
+ filter,
2153
+ resource,
2154
+ "`parseFilterAST` answered `undefined` for a non-empty filter"
2155
+ );
2156
+ }
2157
+ return lowered;
2158
+ }
1633
2159
  function serializeOrderBy(orderby) {
1634
2160
  if (orderby === void 0 || orderby === null) return void 0;
1635
2161
  if (typeof orderby === "string") {
@@ -1692,22 +2218,134 @@ var APP_PERMISSION_DENIED_CODE = "PERMISSION_DENIED";
1692
2218
  function isAppPermissionDeniedError(error) {
1693
2219
  return errorCodeIs(error, APP_PERMISSION_DENIED_CODE);
1694
2220
  }
2221
+ var META_ITEM_ABSENT_CODE = "RESOURCE_NOT_FOUND";
2222
+ function isMetaItemAbsentError(error) {
2223
+ return errorCodeIs(error, META_ITEM_ABSENT_CODE);
2224
+ }
2225
+ function metaEnvelopeCarriesItem(envelope) {
2226
+ if (envelope == null || typeof envelope !== "object") return false;
2227
+ return envelope.item != null;
2228
+ }
2229
+ var VIEW_CONFIG_CAPABILITY = "manage_metadata";
2230
+ var ViewConfigPermissionDeniedError = class extends Error {
2231
+ constructor(objectName, viewId) {
2232
+ super(
2233
+ `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.`
2234
+ );
2235
+ __publicField(this, "code", "VIEW_CONFIG_PERMISSION_DENIED");
2236
+ /** The capability that was required and not held. */
2237
+ __publicField(this, "capability", VIEW_CONFIG_CAPABILITY);
2238
+ /** The object whose view configuration the refused write targeted. */
2239
+ __publicField(this, "objectName");
2240
+ /** The view id the refused write targeted. */
2241
+ __publicField(this, "viewId");
2242
+ this.name = "ViewConfigPermissionDeniedError";
2243
+ this.objectName = objectName;
2244
+ this.viewId = viewId;
2245
+ }
2246
+ };
2247
+ function isViewConfigPermissionDeniedError(error) {
2248
+ if (!error || typeof error !== "object") return false;
2249
+ const e = error;
2250
+ return e.code === "VIEW_CONFIG_PERMISSION_DENIED" || e.name === "ViewConfigPermissionDeniedError";
2251
+ }
1695
2252
  var AnalyticsNotInstalledError = class extends Error {
1696
- constructor(surface, detail) {
2253
+ constructor(surface, detail, serverCode) {
1697
2254
  super(
1698
2255
  `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})` : "")
1699
2256
  );
1700
2257
  __publicField(this, "code", "ANALYTICS_NOT_INSTALLED");
1701
2258
  /** The surface that was unavailable, for the message a host renders. */
1702
2259
  __publicField(this, "surface");
2260
+ /**
2261
+ * The server's own ADR-0112 code — the field this branch was chosen BY, when
2262
+ * a producer named one (`NOT_IMPLEMENTED` from the mounted route with no
2263
+ * service behind it, `ROUTE_NOT_FOUND` from the dispatcher when the route is
2264
+ * not mounted at all). Absent for the no-code residual, where a bare
2265
+ * transport 404/501 is all there was to read.
2266
+ *
2267
+ * Additive (objectui#5663): carried so a reader can audit that the headline
2268
+ * and the quoted `detail` below came off the same answer.
2269
+ */
2270
+ __publicField(this, "serverCode");
1703
2271
  this.name = "AnalyticsNotInstalledError";
1704
2272
  this.surface = surface;
2273
+ this.serverCode = serverCode;
1705
2274
  }
1706
2275
  };
1707
2276
  function isAnalyticsNotInstalledError(error) {
1708
2277
  if (!error || typeof error !== "object") return false;
1709
2278
  return error.code === "ANALYTICS_NOT_INSTALLED";
1710
2279
  }
2280
+ var AnalyticsDatasetNotFoundError = class extends Error {
2281
+ constructor(opts) {
2282
+ const subject = opts.datasetName ? `Dataset "${opts.datasetName}"` : "The requested dataset";
2283
+ super(
2284
+ `${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})` : "")
2285
+ );
2286
+ __publicField(this, "code", "ANALYTICS_DATASET_NOT_FOUND");
2287
+ /**
2288
+ * The dataset this query asked for, taken from the REQUEST rather than parsed
2289
+ * back out of the server's prose: the request is what we know for certain,
2290
+ * and re-reading the name out of a message would make the headline depend on
2291
+ * that message's WORDING — the coupling framework#5367 spent a whole issue
2292
+ * removing from the producing route.
2293
+ */
2294
+ __publicField(this, "datasetName");
2295
+ /** The server's own ADR-0112 code — the field this branch was chosen BY. */
2296
+ __publicField(this, "serverCode");
2297
+ /** The server's own message, quoted verbatim in the parenthetical. */
2298
+ __publicField(this, "serverMessage");
2299
+ this.name = "AnalyticsDatasetNotFoundError";
2300
+ this.datasetName = opts.datasetName;
2301
+ this.serverCode = opts.serverCode;
2302
+ this.serverMessage = opts.serverMessage;
2303
+ }
2304
+ };
2305
+ var AnalyticsUnauthenticatedError = class extends Error {
2306
+ constructor(opts = {}) {
2307
+ super(
2308
+ "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})` : "")
2309
+ );
2310
+ __publicField(this, "code", "ANALYTICS_UNAUTHENTICATED");
2311
+ /** The server's own ADR-0112 code — the field this branch was chosen BY. */
2312
+ __publicField(this, "serverCode");
2313
+ /** The server's own message, quoted verbatim in the parenthetical. */
2314
+ __publicField(this, "serverMessage");
2315
+ this.name = "AnalyticsUnauthenticatedError";
2316
+ this.serverCode = opts.serverCode;
2317
+ this.serverMessage = opts.serverMessage;
2318
+ }
2319
+ };
2320
+ var AnalyticsForbiddenError = class extends Error {
2321
+ constructor(opts = {}) {
2322
+ super(
2323
+ `Analytics query refused: this user may not read the data behind ${opts.datasetName ? `dataset "${opts.datasetName}"` : "this dataset"}.` + (opts.serverMessage ? ` (server said: ${opts.serverMessage})` : "")
2324
+ );
2325
+ __publicField(this, "code", "PERMISSION_DENIED");
2326
+ __publicField(this, "httpStatus", 403);
2327
+ /** The server's own ADR-0112 code — the field this branch was chosen BY. */
2328
+ __publicField(this, "serverCode");
2329
+ /** The server's own message, verbatim (diagnostics only). */
2330
+ __publicField(this, "serverMessage");
2331
+ /** The dataset the refused query asked for, when it named one. */
2332
+ __publicField(this, "datasetName");
2333
+ this.name = "AnalyticsForbiddenError";
2334
+ this.serverCode = opts.serverCode;
2335
+ this.serverMessage = opts.serverMessage;
2336
+ this.datasetName = opts.datasetName;
2337
+ }
2338
+ };
2339
+ function readAnalyticsErrorEnvelope(body) {
2340
+ if (!body || typeof body !== "object") return {};
2341
+ const flat = body;
2342
+ const wrapped = flat.error && typeof flat.error === "object" ? flat.error : void 0;
2343
+ const source = wrapped ?? flat;
2344
+ const code = typeof source.code === "string" && source.code.length > 0 ? source.code : void 0;
2345
+ const message = typeof source.message === "string" && source.message.length > 0 ? source.message : typeof flat.error === "string" && flat.error.length > 0 ? flat.error : void 0;
2346
+ return { code, message };
2347
+ }
2348
+ var ANALYTICS_ABSENT_STATUSES = [404, 501];
1711
2349
  var AnalyticsQueryRejectedError = class extends Error {
1712
2350
  constructor(detail, serverCode) {
1713
2351
  super(
@@ -1720,15 +2358,48 @@ var AnalyticsQueryRejectedError = class extends Error {
1720
2358
  this.serverCode = serverCode;
1721
2359
  }
1722
2360
  };
2361
+ var AnalyticsResultShapeError = class extends Error {
2362
+ constructor(resource, result) {
2363
+ const value = result;
2364
+ const envelope = Array.isArray(value?.data?.rows);
2365
+ const received = result === null ? "null" : Array.isArray(result) ? "an array" : typeof result !== "object" ? typeof result : `an object with keys [${Object.keys(result).join(", ")}]`;
2366
+ super(
2367
+ 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).`
2368
+ );
2369
+ __publicField(this, "code", "ANALYTICS_RESULT_SHAPE_INVALID");
2370
+ /** The object `aggregate()` was called for. */
2371
+ __publicField(this, "resource");
2372
+ /**
2373
+ * True when the value is the envelope the client stopped handing back at
2374
+ * 17.3.0 — `{ success, data: { rows } }`, or its `{ data: { rows } }` core.
2375
+ */
2376
+ __publicField(this, "envelope");
2377
+ /** What the value was, so the producer is identifiable from a log. */
2378
+ __publicField(this, "received");
2379
+ this.name = "AnalyticsResultShapeError";
2380
+ this.resource = resource;
2381
+ this.envelope = envelope;
2382
+ this.received = received;
2383
+ }
2384
+ };
1723
2385
  function classifyAnalyticsFailure(error) {
1724
2386
  const err = error ?? {};
1725
- const code = typeof err.code === "string" ? err.code : void 0;
2387
+ const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
1726
2388
  const message = typeof err.message === "string" ? err.message : void 0;
1727
2389
  const status = typeof err.httpStatus === "number" ? err.httpStatus : typeof err.status === "number" ? err.status : typeof err.statusCode === "number" ? err.statusCode : void 0;
1728
- if (code === "VALIDATION_FAILED" || status === 400) return { kind: "rejected", code, message };
1729
- if (status === 404 || status === 501 || code === "NOT_IMPLEMENTED" || code === "ROUTE_NOT_FOUND") {
2390
+ if (errorCodeIsAnyOf({ code }, ["NOT_IMPLEMENTED", "ROUTE_NOT_FOUND"])) {
1730
2391
  return { kind: "not-installed", code, message };
1731
2392
  }
2393
+ if (errorCodeIs({ code }, "VALIDATION_FAILED")) return { kind: "rejected", code, message };
2394
+ if (errorCodeIs({ code }, "UNAUTHENTICATED")) return { kind: "unauthenticated", code, message };
2395
+ if (errorCodeIs({ code }, "CUBE_NOT_FOUND")) return { kind: "cube-not-found", code, message };
2396
+ if (status === 400) return { kind: "rejected", code, message };
2397
+ if (code === void 0) {
2398
+ if (status !== void 0 && ANALYTICS_ABSENT_STATUSES.includes(status)) {
2399
+ return { kind: "not-installed", code, message };
2400
+ }
2401
+ if (status === 401) return { kind: "unauthenticated", code, message };
2402
+ }
1732
2403
  return { kind: "unknown", code, message };
1733
2404
  }
1734
2405
  var ConcurrentUpdateError = class extends Error {
@@ -1738,9 +2409,22 @@ var ConcurrentUpdateError = class extends Error {
1738
2409
  __publicField(this, "httpStatus", 409);
1739
2410
  __publicField(this, "currentVersion");
1740
2411
  __publicField(this, "currentRecord");
2412
+ /**
2413
+ * The refusal text the PRODUCER marked as addressed to the end user
2414
+ * (`ApiErrorSchema.userMessage`, objectstack `79c46da90`), or `null` when the
2415
+ * refusal carried no marking.
2416
+ *
2417
+ * Declared on the class because this error has no `details` bag: the shared
2418
+ * reader (`declaredUserMessage` in `@object-ui/react`) looks at the
2419
+ * top-level field first, so parking it here is what makes the marking
2420
+ * readable at the surface. The contract is status-agnostic — a 409 carries
2421
+ * it exactly like a 400 or a 403 does.
2422
+ */
2423
+ __publicField(this, "userMessage");
1741
2424
  this.name = "ConcurrentUpdateError";
1742
2425
  this.currentVersion = opts.currentVersion;
1743
2426
  this.currentRecord = opts.currentRecord;
2427
+ this.userMessage = opts.userMessage ?? null;
1744
2428
  }
1745
2429
  };
1746
2430
  function isConcurrentUpdateError(error) {
@@ -1748,10 +2432,18 @@ function isConcurrentUpdateError(error) {
1748
2432
  const e = error;
1749
2433
  return e.code === "CONCURRENT_UPDATE" || e.name === "ConcurrentUpdateError";
1750
2434
  }
2435
+ function liftUserMessage(e, details) {
2436
+ if (typeof e.userMessage === "string" && e.userMessage.trim()) return e.userMessage;
2437
+ if (typeof details.userMessage === "string" && details.userMessage.trim()) {
2438
+ return details.userMessage;
2439
+ }
2440
+ return null;
2441
+ }
1751
2442
  function normaliseClientError(error) {
1752
2443
  if (!error || typeof error !== "object") return error;
1753
2444
  const e = error;
1754
2445
  const details = e.details ?? {};
2446
+ const userMessage = liftUserMessage(e, details);
1755
2447
  if (e.code === "VALIDATION_FAILED" || e.name === "ValidationError") {
1756
2448
  const rawFields = Array.isArray(details.fields) ? details.fields : Array.isArray(e.fields) ? e.fields : [];
1757
2449
  const validationErrors = rawFields.map((f) => {
@@ -1765,15 +2457,21 @@ function normaliseClientError(error) {
1765
2457
  typeof e.message === "string" ? e.message : "Validation failed",
1766
2458
  validationErrors[0]?.field,
1767
2459
  validationErrors,
1768
- { fields: rawFields }
2460
+ // The marking rides the details bag exactly the way `fields` already
2461
+ // does, landing on `err.details.userMessage` — the second of the two
2462
+ // places the shared reader looks. Purely additive: `field`,
2463
+ // `validationErrors` and `fields` are untouched, so the per-field
2464
+ // marking a form already draws keeps working. Omitted entirely when
2465
+ // unmarked, so an unmarked refusal carries no empty key.
2466
+ userMessage === null ? { fields: rawFields } : { fields: rawFields, userMessage }
1769
2467
  );
1770
2468
  }
1771
- if (e.code !== "CONCURRENT_UPDATE" && e.httpStatus !== 409) return error;
1772
2469
  if (e.code !== "CONCURRENT_UPDATE") return error;
1773
2470
  return new ConcurrentUpdateError({
1774
2471
  currentVersion: typeof details.currentVersion === "string" ? details.currentVersion : null,
1775
2472
  currentRecord: details.currentRecord ?? null,
1776
- message: typeof e.message === "string" ? e.message : void 0
2473
+ message: typeof e.message === "string" ? e.message : void 0,
2474
+ userMessage
1777
2475
  });
1778
2476
  }
1779
2477
  function formatHttpFailureMessage(message, meta) {
@@ -1801,8 +2499,14 @@ function createQuietHttpLogger() {
1801
2499
  return false;
1802
2500
  };
1803
2501
  const logger = {
1804
- debug: (message, meta) => console.debug(message, meta ?? ""),
1805
- info: (message, meta) => console.info(message, meta ?? ""),
2502
+ debug: (message, meta) => (
2503
+ // eslint-disable-next-line no-console -- objectui#4029, see comment above
2504
+ console.debug(message, meta ?? "")
2505
+ ),
2506
+ info: (message, meta) => (
2507
+ // eslint-disable-next-line no-console -- objectui#4029, see comment above
2508
+ console.info(message, meta ?? "")
2509
+ ),
1806
2510
  warn: (message, meta) => console.warn(message, meta ?? ""),
1807
2511
  error: (message, error, meta) => {
1808
2512
  if (isExpected404(meta)) {
@@ -1815,12 +2519,87 @@ function createQuietHttpLogger() {
1815
2519
  console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? "", meta ?? "");
1816
2520
  },
1817
2521
  fatal: (message, error, meta) => console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? "", meta ?? ""),
2522
+ // eslint-disable-next-line no-console -- objectui#4029, see comment above
1818
2523
  log: (message, ...args) => console.log(message, ...args),
1819
2524
  child: () => logger,
1820
2525
  withTrace: () => logger
1821
2526
  };
1822
2527
  return logger;
1823
2528
  }
2529
+ var RECOGNIZED_DROP_REASONS = new Set(
2530
+ import_data4.DroppedFieldsEventSchema.shape.reason.options
2531
+ );
2532
+ var UNRECOGNIZED_DROP_REASON = "objectui:unrecognized-drop-reason";
2533
+ function isRecognizedDropReason(reason) {
2534
+ return RECOGNIZED_DROP_REASONS.has(reason);
2535
+ }
2536
+ function isWireDroppedFieldsEntry(e) {
2537
+ return !!e && typeof e === "object" && Array.isArray(e.fields) && e.fields.some((f) => typeof f === "string");
2538
+ }
2539
+ function asDroppedFieldsNotice(entry, fallbackObject) {
2540
+ const fields = entry.fields.filter((f) => typeof f === "string");
2541
+ const object = typeof entry.object === "string" ? entry.object : fallbackObject;
2542
+ if (isRecognizedDropReason(entry.reason)) {
2543
+ return { ...entry, object, fields, reason: entry.reason };
2544
+ }
2545
+ return {
2546
+ ...entry,
2547
+ object,
2548
+ fields,
2549
+ reason: UNRECOGNIZED_DROP_REASON,
2550
+ unrecognizedReason: entry.reason
2551
+ };
2552
+ }
2553
+ var UNATTRIBUTED_STRIP_OBJECT = "";
2554
+ var META_ROUTE_ABSENT_CODES = ["ROUTE_NOT_FOUND", "NOT_IMPLEMENTED"];
2555
+ var IMPORT_MAPPINGS_UNKNOWN_KIND_CODE = "INVALID_REQUEST";
2556
+ var IMPORT_MAPPINGS_UNKNOWN_KIND_STATUS = 400;
2557
+ var META_ABSENT_STATUSES = [404, 501];
2558
+ var META_REFUSAL_CODES = ["UNAUTHENTICATED", "PERMISSION_DENIED"];
2559
+ var META_REFUSAL_STATUSES = [401, 403, 405];
2560
+ function classifyImportMappingsFailure(error) {
2561
+ const err = error ?? {};
2562
+ const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
2563
+ const message = typeof err.message === "string" ? err.message : void 0;
2564
+ const status = typeof err.httpStatus === "number" ? err.httpStatus : typeof err.status === "number" ? err.status : typeof err.statusCode === "number" ? err.statusCode : void 0;
2565
+ const found = { code, status, message };
2566
+ if (errorCodeIsAnyOf({ code }, META_ROUTE_ABSENT_CODES)) {
2567
+ return { kind: "not-served", ...found };
2568
+ }
2569
+ if (errorCodeIs({ code }, IMPORT_MAPPINGS_UNKNOWN_KIND_CODE) && status === IMPORT_MAPPINGS_UNKNOWN_KIND_STATUS) {
2570
+ return { kind: "not-served", ...found };
2571
+ }
2572
+ if (errorCodeIsAnyOf({ code }, META_REFUSAL_CODES)) {
2573
+ return { kind: "refused", ...found };
2574
+ }
2575
+ if (status !== void 0 && META_REFUSAL_STATUSES.includes(status)) {
2576
+ return { kind: "refused", ...found };
2577
+ }
2578
+ if (code === void 0 && status !== void 0 && META_ABSENT_STATUSES.includes(status)) {
2579
+ return { kind: "not-served", ...found };
2580
+ }
2581
+ return { kind: "unreadable", ...found };
2582
+ }
2583
+ function classifyViewsFailure(error) {
2584
+ const err = error ?? {};
2585
+ const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
2586
+ const message = typeof err.message === "string" ? err.message : void 0;
2587
+ const status = typeof err.httpStatus === "number" ? err.httpStatus : typeof err.status === "number" ? err.status : typeof err.statusCode === "number" ? err.statusCode : void 0;
2588
+ const found = { code, status, message };
2589
+ if (errorCodeIsAnyOf({ code }, META_ROUTE_ABSENT_CODES)) {
2590
+ return { kind: "not-served", ...found };
2591
+ }
2592
+ if (errorCodeIsAnyOf({ code }, META_REFUSAL_CODES)) {
2593
+ return { kind: "refused", ...found };
2594
+ }
2595
+ if (status !== void 0 && META_REFUSAL_STATUSES.includes(status)) {
2596
+ return { kind: "refused", ...found };
2597
+ }
2598
+ if (code === void 0 && status !== void 0 && META_ABSENT_STATUSES.includes(status)) {
2599
+ return { kind: "not-served", ...found };
2600
+ }
2601
+ return { kind: "unreadable", ...found };
2602
+ }
1824
2603
  function stableStringify(value) {
1825
2604
  if (value === null || typeof value !== "object") return JSON.stringify(value);
1826
2605
  if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
@@ -1853,15 +2632,48 @@ function withoutNoOpDrops(droppedFields, sent, stored) {
1853
2632
  }
1854
2633
  return out;
1855
2634
  }
2635
+ var VIEW_ITEM_ROUND_TRIP_KEYS = Object.keys(import_ui2.VIEW_CONSOLE_ROUND_TRIP_KEYS).filter((key) => import_ui2.VIEW_CONSOLE_ROUND_TRIP_KEYS[key].includes("viewItem"));
1856
2636
  function viewItemObjectName(item) {
1857
2637
  const spec = item?.list ?? item;
1858
- return spec?.data?.object ?? spec?.object ?? spec?.objectName;
2638
+ return spec?.data?.object ?? spec?.object;
2639
+ }
2640
+ var VIEW_OVERLAY_MARKER = "_isOverride";
2641
+ function isPersonalizationOverlayRow(item, spec) {
2642
+ return item?.[VIEW_OVERLAY_MARKER] === true || spec?.[VIEW_OVERLAY_MARKER] === true;
2643
+ }
2644
+ var VIEW_OVERLAY_OWNED_KEYS = Object.freeze([
2645
+ "rowHeight",
2646
+ "sort",
2647
+ "hiddenFields",
2648
+ "columnState",
2649
+ "inlineEdit"
2650
+ ]);
2651
+ var VIEW_OVERLAY_IDENTITY_KEYS = Object.freeze([
2652
+ "name",
2653
+ "object",
2654
+ "viewKind",
2655
+ VIEW_OVERLAY_MARKER
2656
+ ]);
2657
+ function narrowPersonalizationOverlay(row) {
2658
+ if (!row || typeof row !== "object" || Array.isArray(row)) return row;
2659
+ const item = row;
2660
+ const spec = item.list ?? item;
2661
+ if (!isPersonalizationOverlayRow(item, spec)) return row;
2662
+ const narrowed = {};
2663
+ for (const key of VIEW_OVERLAY_IDENTITY_KEYS) {
2664
+ if (item[key] !== void 0) narrowed[key] = item[key];
2665
+ }
2666
+ for (const key of VIEW_OVERLAY_OWNED_KEYS) {
2667
+ if (item[key] !== void 0) narrowed[key] = item[key];
2668
+ }
2669
+ return narrowed;
1859
2670
  }
1860
2671
  function unwrapViewDraft(resp) {
1861
2672
  if (!resp || typeof resp !== "object") return null;
1862
2673
  const env = resp;
1863
- const body = "item" in env ? env.item : env;
1864
- if (!body || typeof body !== "object") return null;
2674
+ const raw = "item" in env ? env.item : env;
2675
+ if (!raw || typeof raw !== "object") return null;
2676
+ const body = (0, import_kernel2.stripReadDecorations)(raw);
1865
2677
  const spec = body.list ?? body;
1866
2678
  if (!spec || typeof spec !== "object") return null;
1867
2679
  return Object.keys(spec).length > 0 ? spec : null;
@@ -1917,10 +2729,10 @@ var ObjectStackAdapter = class {
1917
2729
  // one adapter that has to cope with a backend lacking server atomicity (#2679).
1918
2730
  __publicField(this, "batchUnsupported", false);
1919
2731
  // The server's declared cross-object atomic-batch capability, read from
1920
- // discovery at connect() (framework #3298 / objectui #2693). `true` → the
2732
+ // discovery at connect() (objectstack-ai/objectstack#3298 / objectui #2693). `true` → the
1921
2733
  // backend GUARANTEES an atomic `/batch`, so batchTransaction trusts it and
1922
2734
  // never degrades to the non-atomic emulation (any failure surfaces as a real
1923
- // error). `false` or `undefined` (capability absent → backend predates #3298)
2735
+ // error). `false` or `undefined` (capability absent → backend predates objectstack-ai/objectstack#3298)
1924
2736
  // → keep the legacy runtime-probe + emulation fallback so a save is still
1925
2737
  // possible; dropping it there would turn "saves, less safe" into "no save
1926
2738
  // path" on older backends (#2679 compatibility constraint).
@@ -1931,7 +2743,7 @@ var ObjectStackAdapter = class {
1931
2743
  // inline-edit "Save All" writes without a manual reload.
1932
2744
  __publicField(this, "mutationListeners", /* @__PURE__ */ new Set());
1933
2745
  // Subscribers registered via onWriteWarning(). Emitted after a create/update
1934
- // whose response carried `droppedFields` (framework #3431/#3455) so the app
2746
+ // whose response carried `droppedFields` (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455) so the app
1935
2747
  // shell can surface a toast instead of the strip passing silently.
1936
2748
  __publicField(this, "writeWarningListeners", /* @__PURE__ */ new Set());
1937
2749
  // Subscribers registered via onSaveAdvisory(). Emitted after a metadata save
@@ -1940,6 +2752,18 @@ var ObjectStackAdapter = class {
1940
2752
  // above in every respect except which door produced the event: that one is
1941
2753
  // record CRUD, this one is the metadata save door.
1942
2754
  __publicField(this, "saveAdvisoryListeners", /* @__PURE__ */ new Set());
2755
+ // Subscribers registered via onMetadataReadWarning(). Emitted when a metadata
2756
+ // READ failed in a way that is NOT the supported "this deployment does not
2757
+ // serve that kind" shape, and was degraded to an empty result anyway
2758
+ // (objectui#7741). Third sibling of the two sets above: same seam shape, and
2759
+ // the opposite direction — those two describe a write that succeeded, this
2760
+ // one a read that did not happen.
2761
+ __publicField(this, "metadataReadWarningListeners", /* @__PURE__ */ new Set());
2762
+ // [ADR-0066] The session's REPORTED system capabilities, pushed in by the
2763
+ // host (see `setSystemCapabilities`). `undefined` means NO answer was ever
2764
+ // reported — which is NOT the same as a reported-empty grant, and the two
2765
+ // are treated differently by `maySetViewConfig` below.
2766
+ __publicField(this, "systemCapabilities");
1943
2767
  this.client = new import_client.ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
1944
2768
  this.installSaveAdvisoryInterceptor();
1945
2769
  this.metadataCache = new MetadataCache(config.cache);
@@ -1948,6 +2772,7 @@ var ObjectStackAdapter = class {
1948
2772
  this.reconnectDelay = config.reconnectDelay ?? 1e3;
1949
2773
  this.baseUrl = config.baseUrl;
1950
2774
  this.token = config.token;
2775
+ this.systemCapabilities = config.systemCapabilities;
1951
2776
  this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
1952
2777
  }
1953
2778
  /**
@@ -2121,7 +2946,7 @@ var ObjectStackAdapter = class {
2121
2946
  * — a per-object metadata-driven search (ADR-0061) — this consults the search
2122
2947
  * index and ranks hits across objects, so it surfaces records the per-object
2123
2948
  * fanout misses. Global affordances (⌘K command palette, search page) prefer
2124
- * this path (framework #3371).
2949
+ * this path (objectstack-ai/objectstack#3371).
2125
2950
  *
2126
2951
  * Returns `{ query, hits }`. A backend without the search plugin installed
2127
2952
  * answers `404`; we treat that as "no global search here" and return an empty
@@ -2154,10 +2979,10 @@ var ObjectStackAdapter = class {
2154
2979
  if (!res.ok) {
2155
2980
  if (res.status === 404) return { query: trimmed, hits: [] };
2156
2981
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
2157
- const err = new Error(
2158
- errorBody?.error?.message || errorBody?.message || res.statusText
2159
- );
2982
+ const envelope = readErrorEnvelope(errorBody);
2983
+ const err = new Error(envelope.message ?? res.statusText);
2160
2984
  err.status = res.status;
2985
+ err.code = envelope.code;
2161
2986
  throw err;
2162
2987
  }
2163
2988
  const body = await res.json();
@@ -2260,24 +3085,28 @@ var ObjectStackAdapter = class {
2260
3085
  }
2261
3086
  }
2262
3087
  /**
2263
- * Read `droppedFields` off a create/update response (framework #3431/#3455)
3088
+ * Read `droppedFields` off a create/update response (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455)
2264
3089
  * and, when present, notify write-warning subscribers. Tolerant of a client
2265
3090
  * whose response type predates `droppedFields`: the field is read structurally
2266
3091
  * and validated, so an older client (or a backend that never drops) is a no-op.
3092
+ *
3093
+ * SHAPE decides whether an entry is an event at all (it must name at least one
3094
+ * field); `object`, `fields` and `reason` are then PARSED — an unrecognized
3095
+ * reason routed to the skew arm, a non-string field element refused, a missing
3096
+ * `object` healed from `resource` — never asserted into the union, and the
3097
+ * entry itself never dropped for them (objectui#4934, `f75810e7c`).
2267
3098
  */
2268
3099
  notifyDroppedFields(operation, resource, result, id, sent) {
2269
3100
  const dropped = result?.droppedFields;
2270
3101
  if (!Array.isArray(dropped) || dropped.length === 0) return;
2271
- const valid = dropped.filter(
2272
- (e) => !!e && typeof e === "object" && Array.isArray(e.fields) && e.fields.length > 0
2273
- );
3102
+ const valid = dropped.filter(isWireDroppedFieldsEntry).map((e) => asDroppedFieldsNotice(e, resource));
2274
3103
  const stored = result?.record;
2275
3104
  const droppedFields = withoutNoOpDrops(valid, sent, stored);
2276
3105
  if (droppedFields.length === 0) return;
2277
3106
  this.emitWriteWarning({ operation, resource, ...id !== void 0 ? { id } : {}, droppedFields });
2278
3107
  }
2279
3108
  /**
2280
- * Same, for the cross-object transactional batch (framework #3794). Its
3109
+ * Same, for the cross-object transactional batch (objectstack-ai/objectstack#3794). Its
2281
3110
  * response hangs the events off a top-level `droppedFields` list, each tagged
2282
3111
  * with the `index` of the operation it came from — `results` entries are bare
2283
3112
  * record echoes with nowhere to hang a per-row list.
@@ -2293,13 +3122,13 @@ var ObjectStackAdapter = class {
2293
3122
  if (!Array.isArray(dropped) || dropped.length === 0) return;
2294
3123
  const results = payload?.results;
2295
3124
  for (const entry of dropped) {
2296
- if (!entry || typeof entry !== "object") continue;
3125
+ if (!isWireDroppedFieldsEntry(entry)) continue;
2297
3126
  const e = entry;
2298
- if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
2299
3127
  const op = typeof e.index === "number" ? operations[e.index] : void 0;
3128
+ const object = typeof e.object === "string" ? e.object : typeof op?.object === "string" ? op.object : UNATTRIBUTED_STRIP_OBJECT;
2300
3129
  const stored = typeof e.index === "number" && Array.isArray(results) ? results[e.index] : void 0;
2301
3130
  const [live] = withoutNoOpDrops(
2302
- [{ object: e.object, fields: e.fields, reason: e.reason }],
3131
+ [asDroppedFieldsNotice({ fields: e.fields, reason: e.reason }, object)],
2303
3132
  op?.data,
2304
3133
  stored
2305
3134
  );
@@ -2307,7 +3136,7 @@ var ObjectStackAdapter = class {
2307
3136
  const operation = (op?.action ?? "create") === "create" ? "create" : "update";
2308
3137
  this.emitWriteWarning({
2309
3138
  operation,
2310
- resource: e.object ?? op?.object ?? "",
3139
+ resource: object,
2311
3140
  ...op?.id !== void 0 && op?.id !== null ? { id: op.id } : {},
2312
3141
  droppedFields: [live]
2313
3142
  });
@@ -2315,7 +3144,7 @@ var ObjectStackAdapter = class {
2315
3144
  }
2316
3145
  /**
2317
3146
  * Subscribe to write-warning events (a create/update dropped caller-supplied
2318
- * fields — #3431/#3455). Returns an unsubscribe function. The app shell uses
3147
+ * fields — objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455). Returns an unsubscribe function. The app shell uses
2319
3148
  * this to toast the user; the write itself already succeeded.
2320
3149
  */
2321
3150
  onWriteWarning(callback) {
@@ -2329,7 +3158,7 @@ var ObjectStackAdapter = class {
2329
3158
  * advisory findings on a save that SUCCEEDED (#4237; backend
2330
3159
  * objectstack#7435). Returns an unsubscribe function.
2331
3160
  *
2332
- * Deliberately the same seam as {@link onWriteWarning} (#3431/#3455), which
3161
+ * Deliberately the same seam as {@link onWriteWarning} (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455), which
2333
3162
  * is what {@link MetadataSaveAdvisoryEvent}'s own declaration already said it
2334
3163
  * was modelled on. It is a SIBLING of that channel rather than a second
2335
3164
  * payload pushed down it: `WriteWarningEvent` is a closed shape whose
@@ -2366,6 +3195,45 @@ var ObjectStackAdapter = class {
2366
3195
  }
2367
3196
  }
2368
3197
  }
3198
+ /**
3199
+ * Subscribe to metadata read-warning events — a metadata READ that could not
3200
+ * be answered and was degraded to an empty result anyway (objectui#7741).
3201
+ * Returns an unsubscribe function. The app shell uses this to toast the user;
3202
+ * the caller has already been handed the empty result.
3203
+ *
3204
+ * Deliberately the same seam as {@link onWriteWarning} and
3205
+ * {@link onSaveAdvisory}, and a SIBLING of them rather than a payload pushed
3206
+ * down either: both of those announce a write that SUCCEEDED, and an event
3207
+ * saying "this read did not happen" carried on one of them would make it lie
3208
+ * about what occurred. What is reused is the seam's SHAPE — one long-lived
3209
+ * instance with a `subscribe -> unsubscribe` registration that
3210
+ * `AdapterProvider` wires once — not its event type.
3211
+ *
3212
+ * ⛔ Nothing here changes what a caller RECEIVES. `listImportMappings` still
3213
+ * answers `[]` on every failure, including the loud ones; this channel is
3214
+ * added ALONGSIDE that return, which is why it is not a change to a surface
3215
+ * published since `@object-ui/data-objectstack@17.1.0`.
3216
+ */
3217
+ onMetadataReadWarning(callback) {
3218
+ this.metadataReadWarningListeners.add(callback);
3219
+ return () => {
3220
+ this.metadataReadWarningListeners.delete(callback);
3221
+ };
3222
+ }
3223
+ /**
3224
+ * Notify all metadata read-warning subscribers. Isolated exactly like
3225
+ * {@link emitWriteWarning}: a throwing listener must neither break the caller
3226
+ * nor starve the others.
3227
+ */
3228
+ emitMetadataReadWarning(event) {
3229
+ for (const listener of this.metadataReadWarningListeners) {
3230
+ try {
3231
+ listener(event);
3232
+ } catch (err) {
3233
+ console.warn("ObjectStackAdapter: metadata read-warning listener error", err);
3234
+ }
3235
+ }
3236
+ }
2369
3237
  /**
2370
3238
  * Install the ONE emitter for the metadata save door (#4237).
2371
3239
  *
@@ -2423,6 +3291,12 @@ var ObjectStackAdapter = class {
2423
3291
  this.emitSaveAdvisory({
2424
3292
  type,
2425
3293
  name,
3294
+ // #5026 — this interceptor wraps `meta.saveItem`, the SAVE door
3295
+ // (`PUT /meta/:type/:name`) and only that one. The SDK's publish
3296
+ // door (`meta.publishItem`) has no caller in this repo, so wiring
3297
+ // it here would be a surface with no consumer; `MetadataClient` is
3298
+ // where the publish door is actually taken.
3299
+ door: "save",
2426
3300
  mode: result?.state === "draft" ? "draft" : "publish",
2427
3301
  advisories
2428
3302
  });
@@ -2470,7 +3344,7 @@ var ObjectStackAdapter = class {
2470
3344
  data,
2471
3345
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
2472
3346
  );
2473
- this.emitMutation({ type: "update", resource, id, record: { ...result.record } });
3347
+ this.emitMutation({ type: "update", resource, id: String(id), record: { ...result.record } });
2474
3348
  this.notifyDroppedFields("update", resource, result, id, data);
2475
3349
  return result.record;
2476
3350
  } catch (err) {
@@ -2493,7 +3367,7 @@ var ObjectStackAdapter = class {
2493
3367
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
2494
3368
  );
2495
3369
  if (result.success) {
2496
- this.emitMutation({ type: "delete", resource, id });
3370
+ this.emitMutation({ type: "delete", resource, id: String(id) });
2497
3371
  }
2498
3372
  return result.success;
2499
3373
  } catch (err) {
@@ -2594,25 +3468,25 @@ var ObjectStackAdapter = class {
2594
3468
  * @returns Promise resolving to array of results
2595
3469
  */
2596
3470
  /**
2597
- * Cross-object transactional batch (ObjectStack #1604 / ADR-0034 item 4).
3471
+ * Cross-object transactional batch (objectstack-ai/objectstack#1604 / ADR-0034 item 4).
2598
3472
  * Runs the operations in ONE server transaction — commit all or roll back
2599
3473
  * all. A field value of `{ $ref: <earlier op index> }` resolves to that op's
2600
3474
  * created id, so a child can reference its parent created earlier in the same
2601
3475
  * batch (master-detail).
2602
3476
  *
2603
3477
  * Transport: the published `@objectstack/client` SDK method
2604
- * `data.batchTransaction` (framework #3271; shipped since client v16, our
3478
+ * `data.batchTransaction` (objectstack-ai/objectstack#3271; shipped since client v16, our
2605
3479
  * dependency floor). Per AGENTS.md §7 data always flows through the client —
2606
3480
  * never a hand-rolled `fetch('/api/v1/batch')`.
2607
3481
  *
2608
- * Fallback decision — declarative capability negotiation (framework #3298 /
3482
+ * Fallback decision — declarative capability negotiation (objectstack-ai/objectstack#3298 /
2609
3483
  * objectui #2693). At connect() we read `capabilities.transactionalBatch`
2610
3484
  * from discovery:
2611
3485
  * - Declared `true` → the backend GUARANTEES atomicity (declared ===
2612
3486
  * enforced). We TRUST it: any batch failure — including 404/405/501 —
2613
3487
  * surfaces as a real error. No non-atomic client-side compensation. This
2614
3488
  * is the path modern backends take.
2615
- * - Declared `false`, or ABSENT (backend predates #3298) → we can't rely on
3489
+ * - Declared `false`, or ABSENT (backend predates objectstack-ai/objectstack#3298) → we can't rely on
2616
3490
  * server atomicity, so we keep the legacy behaviour: on 404/405 (no
2617
3491
  * endpoint) or 501 (runtime without transactions) degrade to the
2618
3492
  * client-side, NON-atomic {@link emulateBatchTransaction} so a save is
@@ -3036,9 +3910,10 @@ var ObjectStackAdapter = class {
3036
3910
  const res = await this.fetchImpl(url, { method: "GET", headers });
3037
3911
  if (!res.ok) {
3038
3912
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
3039
- const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
3913
+ const envelope = readErrorEnvelope(errorBody);
3914
+ const err = new Error(envelope.message ?? res.statusText);
3040
3915
  err.status = res.status;
3041
- err.code = errorBody?.code ?? errorBody?.error?.code;
3916
+ err.code = envelope.code;
3042
3917
  err.httpStatus = res.status;
3043
3918
  throw err;
3044
3919
  }
@@ -3088,16 +3963,51 @@ var ObjectStackAdapter = class {
3088
3963
  queryParams.set("searchFields", request.searchFields.join(","));
3089
3964
  }
3090
3965
  }
3966
+ return this.fetchExportBlob(resource, queryParams);
3967
+ }
3968
+ /**
3969
+ * Download the server's import template:
3970
+ * `GET /api/v1/data/:object/export?template=true` (objectui#9600).
3971
+ *
3972
+ * Same route as {@link exportDownload}, with the server's template switch. It
3973
+ * sends only `template=true`: the server refuses the export's row parameters
3974
+ * on a template request, and its answer is always an xlsx workbook.
3975
+ *
3976
+ * The server writes the template's labels in the request's locale, and the
3977
+ * import accepts a translated label only in its own request's locale. The
3978
+ * import request goes through `@objectstack/client`, which stamps
3979
+ * `Accept-Language` with the client's locale when one is set (and the host's
3980
+ * `fetch` stamps its own otherwise). This request bypasses the client, so it
3981
+ * stamps the client's locale the same way. Both requests then carry the same
3982
+ * locale.
3983
+ */
3984
+ async downloadImportTemplate(resource) {
3985
+ const locale = this.client.getLocale();
3986
+ return this.fetchExportBlob(
3987
+ resource,
3988
+ new URLSearchParams({ template: "true" }),
3989
+ locale ? { "Accept-Language": locale } : void 0
3990
+ );
3991
+ }
3992
+ /**
3993
+ * GET `/api/v1/data/:object/export` with the given query and answer the body
3994
+ * as a Blob. A failure throws an `Error` that carries the server's `status`
3995
+ * and its ADR-0112 `code`. The one request path {@link exportDownload} and
3996
+ * {@link downloadImportTemplate} share.
3997
+ */
3998
+ async fetchExportBlob(resource, queryParams, extraHeaders) {
3091
3999
  const baseUrl = this.baseUrl.replace(/\/$/, "");
3092
4000
  const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
3093
4001
  const dataPath = hasApiVersionSuffix ? "/data" : "/api/v1/data";
3094
4002
  const url = `${baseUrl}${dataPath}/${encodeURIComponent(resource)}/export?${queryParams.toString()}`;
3095
- const headers = { ...this.getAuthHeaders() };
4003
+ const headers = { ...this.getAuthHeaders(), ...extraHeaders };
3096
4004
  const res = await this.fetchImpl(url, { method: "GET", headers, credentials: "include" });
3097
4005
  if (!res.ok) {
3098
4006
  const errorBody = await res.json().catch(() => ({ message: res.statusText }));
3099
- const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
4007
+ const envelope = readErrorEnvelope(errorBody);
4008
+ const err = new Error(envelope.message ?? res.statusText);
3100
4009
  err.status = res.status;
4010
+ err.code = envelope.code;
3101
4011
  throw err;
3102
4012
  }
3103
4013
  return await res.blob();
@@ -3118,7 +4028,8 @@ var ObjectStackAdapter = class {
3118
4028
  if (Array.isArray(params.$filter)) {
3119
4029
  options.filters = translateFilterArray(params.$filter);
3120
4030
  } else {
3121
- options.filters = convertFiltersToAST(params.$filter);
4031
+ const lowered = convertFiltersToAST(params.$filter);
4032
+ if (lowered !== void 0) options.filters = lowered;
3122
4033
  }
3123
4034
  }
3124
4035
  }
@@ -3238,13 +4149,16 @@ var ObjectStackAdapter = class {
3238
4149
  });
3239
4150
  if (!res.ok) {
3240
4151
  const errBody = await res.json().catch(() => ({ message: res.statusText }));
3241
- const err = new Error(errBody?.error?.message || errBody?.message || res.statusText);
4152
+ const envelope = readErrorEnvelope(errBody);
4153
+ const err = new Error(envelope.message ?? res.statusText);
3242
4154
  err.status = res.status;
4155
+ err.code = envelope.code;
3243
4156
  throw err;
3244
4157
  }
3245
4158
  const body = await res.json();
3246
4159
  const data = body && typeof body === "object" && "success" in body && "data" in body ? body.data : body;
3247
- return data && typeof data === "object" && "item" in data ? data.item : data;
4160
+ const item = data && typeof data === "object" && "item" in data ? data.item : data;
4161
+ return attachObjectSortability(item, data && typeof data === "object" ? data.sortability : void 0);
3248
4162
  }
3249
4163
  /**
3250
4164
  * List every registered object (code- and DB-defined) from the metadata
@@ -3426,25 +4340,94 @@ var ObjectStackAdapter = class {
3426
4340
  }
3427
4341
  }
3428
4342
  /**
3429
- * Persist a view definition for an object.
4343
+ * Persist a toolbar-driven view config patch — density, column widths,
4344
+ * sort, hidden columns, inline edit. Symmetric counterpart to
4345
+ * {@link getView}: writes the row to the server metadata store via
4346
+ * `client.meta.saveItem`, then invalidates the matching cache entry so the
4347
+ * next {@link getView} reflects the new payload. Returns the persisted item
4348
+ * when the server echoes it, otherwise undefined.
4349
+ *
4350
+ * Called from exactly ONE production site — `ObjectView`'s
4351
+ * `persistViewPatch`, for the toolbar toggle — but that ONE call site
4352
+ * fires for BOTH kinds of active tab: a code-defined **system** view (no
4353
+ * row of its own yet) and a genuinely user-created **saved** view (already
4354
+ * has a row — the toggle is editing ITS OWN definition, not laying an
4355
+ * overlay on top of it). Which one a given call means is NOT re-derived
4356
+ * here from the write's shape (objectui#4227's own lesson: shape inference
4357
+ * on this namespace is exactly what let a system view masquerade as
4358
+ * saved) — the caller already knows, via the same `isSavedViewId`
4359
+ * classification that gates the switcher's readonly flag and its five
4360
+ * mutating handlers, and passes it as {@link opts.isSavedView}.
3430
4361
  *
3431
- * Symmetric counterpart to {@link getView}: writes the view to the
3432
- * server metadata store via `client.meta.saveItem`, then invalidates
3433
- * the matching cache entry so the next {@link getView} reflects the
3434
- * new payload. Returns the persisted item when the server echoes it,
3435
- * otherwise undefined.
4362
+ * - `isSavedView` false/omitted (system-view target, the common case and
4363
+ * the default for backward compatibility): stamps
4364
+ * {@link VIEW_OVERLAY_MARKER} so {@link listViews} excludes the row —
4365
+ * the original objectui#4227 fix.
4366
+ * - `isSavedView` true: the marker is withheld. Stamping it here would
4367
+ * flag the saved view's OWN row as a personalization overlay, and
4368
+ * `listViews()` would exclude it on the very next read — the user's own
4369
+ * view would vanish from the switcher the moment they toggled its
4370
+ * density (objectui#4227 follow-up, PM review on PR #4713, measured:
4371
+ * `persistViewPatch` has no gate on which kind of tab is active, and
4372
+ * this method writes to the exact same `(type='view', name=viewId)` key
4373
+ * {@link createView}/the ADR-0034 `viewEnvelope` seam already used for
4374
+ * that view, so the write is an upsert onto the saved view's row, not a
4375
+ * new one).
3436
4376
  *
3437
- * Used by ObjectView for "live" toolbar persistence (density,
3438
- * column widths, sort, etc.) and by the View Config Panel for
3439
- * explicit saves.
4377
+ * Per objectstack#7494's ruling, the overlay this writes (system-view
4378
+ * case) is ORG-WIDE shared view settings, not a per-user preference — a
4379
+ * true per-user scope is a parked v18 direction on the platform side.
3440
4380
  *
3441
4381
  * @param objectName - Object name (e.g. 'lead')
3442
4382
  * @param viewId - View identifier (e.g. 'all_leads')
3443
4383
  * @param config - Full view definition to persist
4384
+ * @param opts.isSavedView - Whether `viewId` already names a saved view
4385
+ * (vs. a system view being personalized for the first time). Omit /
4386
+ * `false` for the default overlay-marking behavior.
3444
4387
  */
3445
- async updateViewConfig(objectName, viewId, config) {
4388
+ /**
4389
+ * [ADR-0066] Tell this adapter which system capabilities the session holds.
4390
+ *
4391
+ * Pass `undefined` for "no answer was reported" (a backend predating
4392
+ * ADR-0066, or no permission provider mounted). Pass `[]` for a real,
4393
+ * reported, empty grant — {@link maySetViewConfig} treats those two
4394
+ * differently and callers must not collapse them, which is the same
4395
+ * distinction `MePermissionsProvider` preserves natively (objectui#4656).
4396
+ */
4397
+ setSystemCapabilities(capabilities) {
4398
+ this.systemCapabilities = capabilities ? [...capabilities] : void 0;
4399
+ }
4400
+ /**
4401
+ * May this session write ORG-WIDE view configuration?
4402
+ *
4403
+ * **Unknown fails OPEN**, deliberately, and this is the one judgement in the
4404
+ * gate worth reading twice. It is the doctrine `PermissionContextValue`
4405
+ * .`hasCapabilities` states for every ADR-0066 gate in this repo
4406
+ * (objectui#4656, framework#3923): the server enforces `manage_metadata` on
4407
+ * the metadata door regardless of what the client believes, so a client-side
4408
+ * denial on MISSING DATA cannot protect anything the server was not already
4409
+ * protecting — it can only break a permitted user. Failing closed here would
4410
+ * have refused the write on every deployment predating ADR-0066 and every
4411
+ * host with no permission provider, which is "break the write for everyone"
4412
+ * wearing the costume of a security fix.
4413
+ *
4414
+ * A REPORTED empty array gates strictly: "holds nothing" is a real answer.
4415
+ */
4416
+ maySetViewConfig() {
4417
+ if (this.systemCapabilities === void 0) return true;
4418
+ return this.systemCapabilities.includes(VIEW_CONFIG_CAPABILITY);
4419
+ }
4420
+ async updateViewConfig(objectName, viewId, config, opts) {
4421
+ if (!this.maySetViewConfig()) {
4422
+ throw new ViewConfigPermissionDeniedError(objectName, viewId);
4423
+ }
3446
4424
  await this.connect();
3447
- const merged = { ...config || {}, object: config?.object || objectName, name: viewId };
4425
+ const merged = {
4426
+ ...config || {},
4427
+ object: config?.object || objectName,
4428
+ name: viewId,
4429
+ ...opts?.isSavedView ? {} : { [VIEW_OVERLAY_MARKER]: true }
4430
+ };
3448
4431
  const result = await this.client.meta.saveItem(
3449
4432
  "view",
3450
4433
  viewId,
@@ -3465,6 +4448,42 @@ var ObjectStackAdapter = class {
3465
4448
  * the metadata index is name-only, not field-typed, so the route has no
3466
4449
  * `?object=` to push the filter down into. {@link listViewOverrides}
3467
4450
  * reads the same rows through the same accessor.
4451
+ *
4452
+ * ## Every failure still degrades to an empty list — and now says which kind
4453
+ * ## of failure it was (objectui#8151)
4454
+ *
4455
+ * The degrade is unchanged and deliberate, exactly as it is one method over
4456
+ * in {@link listImportMappings}: a host with no metadata door keeps answering
4457
+ * `[]` rather than breaking the page. What changed is that the OTHER failures
4458
+ * no longer render as that one.
4459
+ *
4460
+ * This method's empty list is read as *this object has no saved views*, and
4461
+ * that reading reaches the user in two places measured on this tree: the list
4462
+ * view switcher simply shows fewer tabs, and `@object-ui/core`'s
4463
+ * `elementDataSourceViewNotFoundMessage` states it outright. So a session
4464
+ * whose token lapsed mid-read was shown an object that appears to have no
4465
+ * saved views AT ALL — including ones the user created — with a
4466
+ * `console.warn` as the only discriminator, in a console nothing in the UI
4467
+ * points at. That is the silence objectui#7741 removed from the sibling after
4468
+ * it produced a confident wrong diagnosis in objectstack#14026.
4469
+ *
4470
+ * The `catch` now asks {@link classifyViewsFailure} what the failure WAS,
4471
+ * reading the error's own ADR-0112 `code` and status — never "is the result
4472
+ * empty", which is what both conditions produce and so can never tell them
4473
+ * apart — and anything that is not the doorless shape is announced on
4474
+ * {@link onMetadataReadWarning}.
4475
+ *
4476
+ * ⛔ That classifier is a SEPARATE reading, not a second caller of the
4477
+ * sibling's: `view`'s quiet set is strictly smaller, because the arm
4478
+ * `listImportMappings` is built around (*an older server without this kind*)
4479
+ * has no members here. See {@link classifyViewsFailure} for the measurement.
4480
+ *
4481
+ * ⛔ The RETURN is untouched. This method has answered `Promise<any[]>`, never
4482
+ * throwing, since `@object-ui/data-objectstack@17.1.0`; a consumer that reads
4483
+ * nothing new sees exactly what it saw before, including on the loud arms.
4484
+ * Applying framework objectstack#13906 decision 1 option A — *a thing that could not be
4485
+ * READ is not a thing that is ABSENT* — is done by ADDING a channel, not by
4486
+ * moving that contract.
3468
4487
  */
3469
4488
  async listViews(objectName, options) {
3470
4489
  await this.connect();
@@ -3483,23 +4502,42 @@ var ObjectStackAdapter = class {
3483
4502
  const spec = v.list ?? v;
3484
4503
  if (viewItemObjectName(v) !== objectName) return false;
3485
4504
  const viewKind = v.viewKind ?? spec?.viewKind;
3486
- return !(viewKind && FORM_FAMILY.has(viewKind));
4505
+ if (viewKind && FORM_FAMILY.has(viewKind)) return false;
4506
+ if (isPersonalizationOverlayRow(v, spec)) return false;
4507
+ return true;
3487
4508
  }).map((v) => {
3488
4509
  const spec = v.list ?? v;
3489
4510
  const isDraft = v._draft === true || spec?._draft === true;
3490
4511
  if (spec && spec.config && typeof spec.config === "object") {
3491
- return {
4512
+ const row = {
3492
4513
  ...spec.config,
3493
4514
  name: spec.name ?? spec.config.name,
3494
- label: spec.label ?? spec.config.label,
3495
- isDefault: !!spec.isDefault,
3496
- ...isDraft ? { _draft: true } : {}
4515
+ label: spec.label ?? spec.config.label
3497
4516
  };
4517
+ if (spec.object !== void 0) row.object = spec.object;
4518
+ for (const key of VIEW_ITEM_ROUND_TRIP_KEYS) {
4519
+ if (spec[key] !== void 0) row[key] = spec[key];
4520
+ }
4521
+ row.isDefault = !!spec.isDefault;
4522
+ if (isDraft) row._draft = true;
4523
+ return row;
3498
4524
  }
3499
4525
  return isDraft ? { ...spec, _draft: true } : spec;
3500
4526
  });
3501
4527
  } catch (err) {
3502
4528
  console.warn("[OBJECTSTACKDataSource] listViews failed:", err);
4529
+ const failure = classifyViewsFailure(err);
4530
+ if (failure.kind !== "not-served") {
4531
+ this.emitMetadataReadWarning({
4532
+ operation: "listViews",
4533
+ kind: "view",
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
+ }
3503
4541
  return [];
3504
4542
  }
3505
4543
  }
@@ -3518,11 +4556,36 @@ var ObjectStackAdapter = class {
3518
4556
  }
3519
4557
  /**
3520
4558
  * List registered import `mapping` artifacts targeting a given object
3521
- * (framework #2611). Reads the `mapping` metadata kind via the overlay API
4559
+ * (objectstack-ai/objectstack#2611). Reads the `mapping` metadata kind via the overlay API
3522
4560
  * and filters by `targetObject` client-side (the metadata index is
3523
- * name-only). Feeds the import wizard's "saved mapping" selector; a failure
3524
- * (older server without the `mapping` kind) degrades to an empty list, so
3525
- * the selector simply doesn't appear.
4561
+ * name-only). Feeds the import wizard's "saved mapping" selector.
4562
+ *
4563
+ * ## Every failure still degrades to an empty list — and now says which kind
4564
+ * ## of failure it was (objectui#7741)
4565
+ *
4566
+ * The degrade is unchanged and deliberate: an older server without the
4567
+ * `mapping` kind must not break the wizard, so it keeps answering `[]` and
4568
+ * the selector simply doesn't appear. That is a real, supported deployment
4569
+ * shape and it stays quiet.
4570
+ *
4571
+ * What changed is that the OTHER failures no longer render as that one. A
4572
+ * refused door and a broken one used to be indistinguishable, in the UI, from
4573
+ * "no mapping is registered" — both were an empty list behind a
4574
+ * `console.warn` nothing pointed at — and that silence produced a confident
4575
+ * wrong diagnosis in a careful reporter (objectstack#14026 was filed, routed
4576
+ * and worked by two seats against a wizard that had been correct since
4577
+ * 17.1.0). So the `catch` now asks {@link classifyImportMappingsFailure} what
4578
+ * the failure WAS, reading the error's own ADR-0112 `code` and status —
4579
+ * never "is the result empty", which is what both conditions produce and so
4580
+ * can never tell them apart — and anything that is not the supported
4581
+ * kind-absent shape is announced on {@link onMetadataReadWarning}.
4582
+ *
4583
+ * ⛔ The RETURN is untouched. This method has answered `Promise<any[]>`, never
4584
+ * throwing, since `@object-ui/data-objectstack@17.1.0`; a consumer that reads
4585
+ * nothing new sees exactly what it saw before, including on the loud arms.
4586
+ * Applying framework objectstack#13906 decision 1 option A — *a thing that could not be
4587
+ * READ is not a thing that is ABSENT* — is done by ADDING a channel, not by
4588
+ * moving that contract.
3526
4589
  */
3527
4590
  async listImportMappings(objectName) {
3528
4591
  await this.connect();
@@ -3532,6 +4595,18 @@ var ObjectStackAdapter = class {
3532
4595
  return items.filter((m) => m && m.targetObject === objectName);
3533
4596
  } catch (err) {
3534
4597
  console.warn("[OBJECTSTACKDataSource] listImportMappings failed:", err);
4598
+ const failure = classifyImportMappingsFailure(err);
4599
+ if (failure.kind !== "not-served") {
4600
+ this.emitMetadataReadWarning({
4601
+ operation: "listImportMappings",
4602
+ kind: "mapping",
4603
+ objectName,
4604
+ reason: failure.kind,
4605
+ ...failure.code !== void 0 ? { code: failure.code } : {},
4606
+ ...failure.status !== void 0 ? { status: failure.status } : {},
4607
+ ...failure.message !== void 0 ? { message: failure.message } : {}
4608
+ });
4609
+ }
3535
4610
  return [];
3536
4611
  }
3537
4612
  }
@@ -3758,7 +4833,7 @@ var ObjectStackAdapter = class {
3758
4833
  try {
3759
4834
  const cacheKey = `app:${appId}`;
3760
4835
  return await this.metadataCache.get(cacheKey, async () => {
3761
- const result = await this.client.meta.getItem("apps", appId);
4836
+ const result = await this.client.meta.getItem("app", appId);
3762
4837
  if (result && result.item) return result.item;
3763
4838
  return result ?? null;
3764
4839
  });
@@ -3770,8 +4845,13 @@ var ObjectStackAdapter = class {
3770
4845
  * Ask the by-name meta app route WHY an app is not in this session's app list
3771
4846
  * (objectui#4252).
3772
4847
  *
3773
- * `GET /api/v1/meta/apps` is filtered per session server-side
3774
- * (`filterAppForUser`), so an app withheld by `requiredPermissions` and an app
4848
+ * The app LIST is the generic metadata list route `GET /api/v1/meta/:type`,
4849
+ * requested with the singular type segment `app` — the same address this
4850
+ * method appends a name to below, and the one `MetadataProvider` reads its
4851
+ * items from. The server filters that list per session in `filterAppForUser`
4852
+ * (`packages/rest/src/rest-server.ts`, applied inside the `:type` list handler
4853
+ * once the type segment resolves to `app`), so an app withheld by
4854
+ * `requiredPermissions` and an app
3775
4855
  * that does not exist are byte-identical there: both are simply absent. A
3776
4856
  * console reading only that list has one fact and two conditions, and it
3777
4857
  * renders its copy for the wrong one — "it may still be publishing" over a
@@ -3781,11 +4861,17 @@ var ObjectStackAdapter = class {
3781
4861
  *
3782
4862
  * The maintainer ruling (2026-08-12) put the answer on the BY-NAME route
3783
4863
  * rather than in the list, so the enumeration surface is not widened past what
3784
- * a by-name probe already implies (objectstack#8013 / PR #8135): an app that
4864
+ * a by-name probe already implies (objectstack#8013 / PR objectstack-ai/objectstack#8135): an app that
3785
4865
  * exists and whose `requiredPermissions` the session lacks answers `403` with
3786
4866
  * `PERMISSION_DENIED` in the declared envelope, and absence — a nonexistent
3787
- * name, an unpublished app, an app gated by an absent optional service —
3788
- * keeps answering `404 RESOURCE_NOT_FOUND`.
4867
+ * name, an unpublished app, an app gated by an absent optional service — is
4868
+ * one undifferentiated answer.
4869
+ *
4870
+ * ⚠️ That absence is NOT uniformly a `404`, which this docstring asserted
4871
+ * until objectui#9262 measured it: the 404 covers an app that EXISTS and is
4872
+ * withheld for a non-permission reason, while a name that resolves to nothing
4873
+ * answers `200` with an envelope carrying no `item`. Both are absence and both
4874
+ * read as `not_found`; see {@link metaEnvelopeCarriesItem} for the measurement.
3789
4875
  *
3790
4876
  * ## Why this is a separate method and not a flavour of {@link getApp}
3791
4877
  *
@@ -3796,19 +4882,41 @@ var ObjectStackAdapter = class {
3796
4882
  * be cached beside a document about the APP — one grant, and a cached
3797
4883
  * denial outlives the session it described.
3798
4884
  *
3799
- * Nothing here throws: a probe that cannot reach an answer returns `unknown`
3800
- * and the caller keeps whatever it was already showing. Only the measured
3801
- * `code` produces `denied` — never a status, never a message (objectui#4408).
4885
+ * Nothing here throws: every outcome is a verdict the caller renders a screen
4886
+ * from, never a catch block. Only the measured `code` produces `denied` —
4887
+ * never a status, never a message (objectui#4408).
4888
+ *
4889
+ * ## objectui#9262 — what this method stopped folding away
4890
+ *
4891
+ * It used to answer `granted` whenever the call did not throw, and `unknown`
4892
+ * for everything that did. Both halves were wrong in the same direction, and
4893
+ * the first was measured on a real server before this change landed:
4894
+ *
4895
+ * - **an app that does not exist answers `200` with the envelope minus its
4896
+ * `item`**, so "did not throw" reported `granted` for a name with nothing
4897
+ * behind it — the shape issue objectui#9262 listed as cause 8 and expected
4898
+ * to be structural. It is the ordinary case: every typo, every never-created
4899
+ * app and every unpublished draft took it. See
4900
+ * {@link metaEnvelopeCarriesItem} for the measurement and the server-side
4901
+ * mechanism.
4902
+ * - a 404 absence and an unreachable server were one `unknown`, though the
4903
+ * transport had already told them apart.
4904
+ *
4905
+ * The verdicts now say only what was obtained: a served document, a refusal
4906
+ * naming permission, an answer that there is no such app here, or no answer
4907
+ * at all. `unknown` is left for the one case where nothing was asked.
3802
4908
  *
3803
4909
  * @param appName - the app name as it appears in the URL segment
3804
4910
  */
3805
4911
  async probeAppAccess(appName) {
3806
4912
  if (!appName) return "unknown";
3807
4913
  try {
3808
- await this.client.meta.getItem("app", appName);
3809
- return "granted";
4914
+ const envelope = await this.client.meta.getItem("app", appName);
4915
+ return metaEnvelopeCarriesItem(envelope) ? "granted" : "not_found";
3810
4916
  } catch (err) {
3811
- return isAppPermissionDeniedError(err) ? "denied" : "unknown";
4917
+ if (isAppPermissionDeniedError(err)) return "denied";
4918
+ if (isMetaItemAbsentError(err)) return "not_found";
4919
+ return "unreachable";
3812
4920
  }
3813
4921
  }
3814
4922
  /**
@@ -3821,7 +4929,7 @@ var ObjectStackAdapter = class {
3821
4929
  try {
3822
4930
  const cacheKey = `page:${pageId}`;
3823
4931
  return await this.metadataCache.get(cacheKey, async () => {
3824
- const result = await this.client.meta.getItem("pages", pageId);
4932
+ const result = await this.client.meta.getItem("page", pageId);
3825
4933
  if (result && result.item) return result.item;
3826
4934
  return result ?? null;
3827
4935
  });
@@ -3858,7 +4966,8 @@ var ObjectStackAdapter = class {
3858
4966
  * Uses `this.client.analytics.query()` from @objectstack/client to leverage
3859
4967
  * the SDK's built-in auth, headers, and fetch configuration.
3860
4968
  * Falls back to client-side aggregation via find() if the analytics endpoint
3861
- * is not available.
4969
+ * is not available. Throws {@link AnalyticsResultShapeError} when it answers
4970
+ * with anything but an `AnalyticsResult` carrying `rows`.
3862
4971
  */
3863
4972
  async aggregate(resource, params) {
3864
4973
  await this.connect();
@@ -3867,7 +4976,11 @@ var ObjectStackAdapter = class {
3867
4976
  const queryAst = {};
3868
4977
  if (Array.isArray(params.groupBy)) queryAst.groupBy = params.groupBy;
3869
4978
  if (Array.isArray(params.aggregations)) queryAst.aggregations = params.aggregations;
3870
- if (params.where !== void 0) queryAst.where = params.where;
4979
+ if (params.where !== void 0) {
4980
+ assertSpecShapeWhereIsFilterAst(params.where, resource);
4981
+ queryAst.where = params.where;
4982
+ }
4983
+ assertNoAnalyticsKeysOnSpecShape(params, resource);
3871
4984
  if (typeof params.limit === "number") queryAst.limit = params.limit;
3872
4985
  const result = await this.client.data.query(resource, queryAst);
3873
4986
  if (Array.isArray(result)) return result;
@@ -3875,6 +4988,7 @@ var ObjectStackAdapter = class {
3875
4988
  if (Array.isArray(result?.data)) return result.data;
3876
4989
  return [];
3877
4990
  }
4991
+ const analyticsWhere = params.filter ? lowerAnalyticsFilterForWire(params.filter, resource) : void 0;
3878
4992
  try {
3879
4993
  const measureName = params.function === "count" ? "count" : `${params.field}_${params.function}`;
3880
4994
  const valueKey = this.aggregateValueKey(params);
@@ -3884,11 +4998,12 @@ var ObjectStackAdapter = class {
3884
4998
  // When groupBy is '_all' no dimensions are needed (single-bucket).
3885
4999
  dimensions: params.groupBy && params.groupBy !== "_all" ? [params.groupBy] : []
3886
5000
  };
3887
- if (params.filter) {
3888
- payload.where = params.filter;
5001
+ if (analyticsWhere !== void 0) payload.where = analyticsWhere;
5002
+ const contractResult = await this.client.analytics.query(payload);
5003
+ if (!Array.isArray(contractResult?.rows)) {
5004
+ throw new AnalyticsResultShapeError(resource, contractResult);
3889
5005
  }
3890
- const data = await this.client.analytics.query(payload);
3891
- 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 : [];
5006
+ const rawRows = contractResult.rows;
3892
5007
  const measureMissing = rawRows.length > 0 && rawRows.every((row) => {
3893
5008
  if (row == null) return true;
3894
5009
  if (measureName in row && row[measureName] != null) return false;
@@ -3907,16 +5022,54 @@ var ObjectStackAdapter = class {
3907
5022
  return mapped;
3908
5023
  });
3909
5024
  } catch (e) {
5025
+ if (e instanceof AnalyticsResultShapeError) throw e;
3910
5026
  const failure = classifyAnalyticsFailure(e);
3911
5027
  if (failure.kind === "rejected") {
3912
5028
  throw new AnalyticsQueryRejectedError(failure.message, failure.code);
3913
5029
  }
5030
+ if (failure.kind === "unauthenticated") {
5031
+ throw new AnalyticsUnauthenticatedError({
5032
+ serverCode: failure.code,
5033
+ serverMessage: failure.message
5034
+ });
5035
+ }
5036
+ if (failure.kind === "cube-not-found") {
5037
+ throw e;
5038
+ }
3914
5039
  if (failure.kind === "not-installed") {
3915
5040
  this.warnAnalyticsCapabilityOnce(failure.message);
3916
5041
  }
3917
5042
  return await this.aggregateViaFind(resource, params);
3918
5043
  }
3919
5044
  }
5045
+ /**
5046
+ * Answer a grouped list view's GROUP HEADER query (objectui#7189) — see
5047
+ * `DataSource.queryGroupHeaders` in `@object-ui/types` for the contract.
5048
+ *
5049
+ * The compiled query rides the platform's EXISTING data door verbatim:
5050
+ * `client.data.query()` posts it to `POST /data/:object/query`, whose
5051
+ * `findData` routes a body carrying `groupBy` / `aggregations` to
5052
+ * `engine.aggregate` and answers the header rows as `records` (the door the
5053
+ * spec half of objectstack#14556 names, pinned platform-side by
5054
+ * objectstack#15330). No new route, no new wire shape.
5055
+ *
5056
+ * ⛔ No fallback, deliberately — unlike {@link aggregate}. A header query that
5057
+ * degraded to bucketing a fetched page would answer page slices under a name
5058
+ * that promises the query's own numbers, which is the defect this member
5059
+ * exists to end. A failure is thrown to the caller, and a body without a
5060
+ * `records` array is refused rather than read as "no groups".
5061
+ */
5062
+ async queryGroupHeaders(resource, query) {
5063
+ await this.connect();
5064
+ const result = await this.client.data.query(resource, query);
5065
+ const records = result?.records;
5066
+ if (!Array.isArray(records)) {
5067
+ throw new Error(
5068
+ `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.`
5069
+ );
5070
+ }
5071
+ return records;
5072
+ }
3920
5073
  /**
3921
5074
  * Client-side aggregation over a server-scoped `find()` — the fallback used
3922
5075
  * whenever the analytics endpoint cannot answer (capability absent, network
@@ -3990,14 +5143,42 @@ var ObjectStackAdapter = class {
3990
5143
  body: JSON.stringify(requestBody)
3991
5144
  });
3992
5145
  if (!res.ok) {
3993
- let detail = "";
5146
+ let errBody;
3994
5147
  try {
3995
- const errBody = await res.json();
3996
- detail = errBody?.message || errBody?.error || JSON.stringify(errBody);
5148
+ errBody = await res.json();
3997
5149
  } catch {
3998
5150
  }
3999
- if (res.status === 501 || res.status === 404) {
4000
- throw new AnalyticsNotInstalledError("POST /analytics/dataset/query", detail || void 0);
5151
+ const { code: serverCode, message: serverMessage } = readAnalyticsErrorEnvelope(errBody);
5152
+ const detail = serverMessage || (errBody === void 0 ? "" : JSON.stringify(errBody));
5153
+ const surface = "POST /analytics/dataset/query";
5154
+ const requestedDatasetName = typeof dataset === "string" ? dataset : typeof dataset?.name === "string" ? String(dataset.name) : void 0;
5155
+ if (errorCodeIsAnyOf({ code: serverCode }, ["NOT_IMPLEMENTED", "ROUTE_NOT_FOUND"])) {
5156
+ throw new AnalyticsNotInstalledError(surface, serverMessage, serverCode);
5157
+ }
5158
+ if (errorCodeIs({ code: serverCode }, "NOT_FOUND")) {
5159
+ throw new AnalyticsDatasetNotFoundError({
5160
+ datasetName: requestedDatasetName,
5161
+ serverCode,
5162
+ serverMessage
5163
+ });
5164
+ }
5165
+ if (errorCodeIs({ code: serverCode }, "UNAUTHENTICATED")) {
5166
+ throw new AnalyticsUnauthenticatedError({ serverCode, serverMessage });
5167
+ }
5168
+ if (errorCodeIs({ code: serverCode }, "PERMISSION_DENIED")) {
5169
+ throw new AnalyticsForbiddenError({
5170
+ serverCode,
5171
+ serverMessage,
5172
+ datasetName: requestedDatasetName
5173
+ });
5174
+ }
5175
+ if (serverCode === void 0) {
5176
+ if (ANALYTICS_ABSENT_STATUSES.includes(res.status)) {
5177
+ throw new AnalyticsNotInstalledError(surface, detail || void 0);
5178
+ }
5179
+ if (res.status === 401) {
5180
+ throw new AnalyticsUnauthenticatedError({ serverMessage });
5181
+ }
4001
5182
  }
4002
5183
  throw new Error(`Dataset query failed: ${res.status} ${res.statusText}${detail ? ` \u2014 ${detail}` : ""}`);
4003
5184
  }
@@ -4131,9 +5312,10 @@ var ObjectStackAdapter = class {
4131
5312
  }
4132
5313
  });
4133
5314
  if (!response.ok) {
4134
- const error = await response.json().catch(() => ({ message: response.statusText }));
5315
+ const errorBody = await response.json().catch(() => ({ message: response.statusText }));
5316
+ const envelope = readErrorEnvelope(errorBody);
4135
5317
  throw new ObjectStackError(
4136
- error.message || `Upload failed with status ${response.status}`,
5318
+ envelope.message ?? `Upload failed with status ${response.status}`,
4137
5319
  "UPLOAD_ERROR",
4138
5320
  response.status
4139
5321
  );
@@ -4173,9 +5355,10 @@ var ObjectStackAdapter = class {
4173
5355
  }
4174
5356
  });
4175
5357
  if (!response.ok) {
4176
- const error = await response.json().catch(() => ({ message: response.statusText }));
5358
+ const errorBody = await response.json().catch(() => ({ message: response.statusText }));
5359
+ const envelope = readErrorEnvelope(errorBody);
4177
5360
  throw new ObjectStackError(
4178
- error.message || `Upload failed with status ${response.status}`,
5361
+ envelope.message ?? `Upload failed with status ${response.status}`,
4179
5362
  "UPLOAD_ERROR",
4180
5363
  response.status
4181
5364
  );
@@ -4257,31 +5440,47 @@ function createObjectStackAdapter(config) {
4257
5440
  0 && (module.exports = {
4258
5441
  API_ACCESS_DENIED_CODES,
4259
5442
  APP_PERMISSION_DENIED_CODE,
5443
+ AnalyticsDatasetNotFoundError,
5444
+ AnalyticsForbiddenError,
5445
+ AnalyticsKeysOnSpecShapeError,
4260
5446
  AnalyticsNotInstalledError,
4261
5447
  AnalyticsQueryRejectedError,
5448
+ AnalyticsResultShapeError,
5449
+ AnalyticsUnauthenticatedError,
4262
5450
  AuthenticationError,
4263
5451
  BulkOperationError,
5452
+ CHOICE_TYPES_REQUIRING_OPTIONS,
4264
5453
  ConcurrentUpdateError,
4265
5454
  ConnectionError,
4266
5455
  DataApiValidationError,
4267
5456
  FILTER_OPERATOR_ALIASES,
4268
- IntegrationManager,
5457
+ META_ITEM_ABSENT_CODE,
4269
5458
  MalformedFilterError,
4270
5459
  MetadataClient,
4271
5460
  MetadataNotFoundError,
5461
+ OBJECT_METADATA_TYPE,
4272
5462
  ObjectStackAdapter,
4273
5463
  ObjectStackError,
4274
- SecurityManager,
4275
- calculateAutoLayout,
5464
+ RELATIONSHIP_TYPES_REQUIRING_REFERENCE,
5465
+ UNRECOGNIZED_DROP_REASON,
5466
+ UnlowerableAnalyticsFilterError,
5467
+ UnloweredAggregateWhereError,
5468
+ VIEW_CONFIG_CAPABILITY,
5469
+ VIEW_OVERLAY_OWNED_KEYS,
5470
+ ViewConfigPermissionDeniedError,
5471
+ assertObjectMetadataWritable,
4276
5472
  classifyAnalyticsFailure,
5473
+ classifyImportMappingsFailure,
5474
+ classifyViewsFailure,
4277
5475
  clearSharedDiscoveryCache,
4278
- createDefaultCanvasConfig,
4279
5476
  createErrorFromResponse,
4280
5477
  createObjectStackAdapter,
4281
5478
  createObjectStackUserStateAdapter,
4282
5479
  createQuietHttpLogger,
5480
+ extractDraftBody,
4283
5481
  formatHttpFailureMessage,
4284
- generateContractManifest,
5482
+ formatMetadataError,
5483
+ formatMetadataIssue,
4285
5484
  getSharedDiscovery,
4286
5485
  is404Error,
4287
5486
  isAnalyticsNotInstalledError,
@@ -4290,12 +5489,13 @@ function createObjectStackAdapter(config) {
4290
5489
  isConcurrentUpdateError,
4291
5490
  isErrorType,
4292
5491
  isMalformedFilterError,
5492
+ isMetaItemAbsentError,
4293
5493
  isObjectStackError,
5494
+ isViewConfigPermissionDeniedError,
5495
+ narrowPersonalizationOverlay,
4294
5496
  normaliseClientError,
4295
5497
  readSaveAdvisories,
4296
5498
  readTransactionalBatchCapability,
4297
5499
  serializeOrderBy,
4298
- snapToGrid,
4299
- validatePluginContract,
4300
5500
  viewItemObjectName
4301
5501
  });