@object-ui/data-objectstack 17.7.0 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -8,10 +8,11 @@ import { VIEW_CONSOLE_ROUND_TRIP_KEYS } from "@objectstack/spec/ui";
8
8
  import { DroppedFieldsEventSchema } from "@objectstack/spec/data";
9
9
  import { isFilterAST, parseFilterAST } from "@objectstack/spec/data";
10
10
  import { isRefusedTextComparand as isRefusedTextComparand2, textComparandRefusalReason as textComparandRefusalReason2 } from "@objectstack/spec/data";
11
+ import { ValidateDataIssueSchema } from "@objectstack/spec/api";
11
12
  import { stripReadDecorations as stripReadDecorations2 } from "@objectstack/spec/kernel";
12
13
 
13
14
  // src/metadata-client.ts
14
- import { GetMetaItemLayeredResponseSchema } from "@objectstack/spec/api";
15
+ import { FlowRegistrationSchema, GetMetaItemLayeredResponseSchema } from "@objectstack/spec/api";
15
16
 
16
17
  // src/object-metadata-write-guard.ts
17
18
  var RELATIONSHIP_TYPES_REQUIRING_REFERENCE = ["lookup", "master_detail"];
@@ -52,20 +53,20 @@ function fieldEntries(fields) {
52
53
  }
53
54
  return [];
54
55
  }
55
- function assertObjectMetadataWritable(type, item, writer) {
56
+ function assertObjectMetadataWritable(type, item, _writer) {
56
57
  if (String(type) !== OBJECT_METADATA_TYPE) return;
57
58
  if (!item || typeof item !== "object") return;
58
59
  const fields = item.fields;
59
60
  for (const { name, def } of fieldEntries(fields)) {
60
61
  if (CHOICE_TYPES_REQUIRING_OPTIONS.includes(String(def.type)) && !hasOptionSource(def)) {
61
62
  throw new Error(
62
- `${writer} refused this object metadata write: the field \`${name}\` is a \`${String(def.type)}\` with no options: it carries ${describeOptions(def.options)}, so it offers nothing to choose and nothing to check a stored value against. A choice field needs at least one option or a shared \`picklist\` (objectstack#20827), so this draft stays here until it has one. Add an option, or change the field to a non-choice type.`
63
+ `The object was not saved: the field \`${name}\` is a \`${String(def.type)}\` with no options: it carries ${describeOptions(def.options)}, so it offers nothing to choose and nothing to check a stored value against. A choice field needs at least one option or a shared \`picklist\`, so this draft stays here until it has one. Add an option, or change the field to a non-choice type.`
63
64
  );
64
65
  }
65
66
  if (!RELATIONSHIP_TYPES_REQUIRING_REFERENCE.includes(String(def.type))) continue;
66
67
  if (isUsableTarget(def.reference)) continue;
67
68
  throw new Error(
68
- `${writer} refused this object metadata write: the field \`${name}\` is a \`${String(def.type)}\` and carries ${describeTarget(def.reference)}, so it names no object to link to. \`@objectstack/spec\` refuses the same document at the server with a 422 on \`fields.${name}.reference\`, and that refusal blocks every later save of this object for as long as the half-filled field rides along in the draft (objectui#7714). Pick the target object, or change the field to a non-relationship type.`
69
+ `The object was not saved: the field \`${name}\` is a \`${String(def.type)}\` and carries ${describeTarget(def.reference)}, so it names no object to link to. The server refuses the same document with a 422 on \`fields.${name}.reference\`, and that refusal blocks every later save of this object for as long as the half-filled field rides along in the draft. Pick the target object, or change the field to a non-relationship type.`
69
70
  );
70
71
  }
71
72
  }
@@ -81,6 +82,15 @@ function readSaveAdvisories(body) {
81
82
  return typeof c.rule === "string" && typeof c.path === "string" && typeof c.where === "string" && typeof c.message === "string" && typeof c.hint === "string" && typeof c.severity === "string";
82
83
  });
83
84
  }
85
+ function readRegistrations(raw) {
86
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
87
+ const out = {};
88
+ for (const [name, verdict] of Object.entries(raw)) {
89
+ const parsed = FlowRegistrationSchema.safeParse(verdict);
90
+ if (parsed.success) out[name] = parsed.data;
91
+ }
92
+ return out;
93
+ }
84
94
  var PROTECTION_ENVELOPE_KEYS = [
85
95
  "overlayScope",
86
96
  "lock",
@@ -147,6 +157,26 @@ function isMetaItemEnvelope(value) {
147
157
  const env = value;
148
158
  return typeof env.type === "string" && typeof env.name === "string" && "item" in env;
149
159
  }
160
+ var pendingReads = /* @__PURE__ */ new WeakMap();
161
+ function pendingReadsOn(transport) {
162
+ let reads = pendingReads.get(transport);
163
+ if (!reads) {
164
+ reads = /* @__PURE__ */ new Map();
165
+ pendingReads.set(transport, reads);
166
+ }
167
+ return reads;
168
+ }
169
+ function handOut(entry) {
170
+ entry.waiting += 1;
171
+ return entry.read.then((value) => {
172
+ entry.waiting -= 1;
173
+ return entry.waiting === 0 ? value : ownCopy(value);
174
+ });
175
+ }
176
+ function ownCopy(value) {
177
+ if (value === null || typeof value !== "object") return value;
178
+ return typeof structuredClone === "function" ? structuredClone(value) : JSON.parse(JSON.stringify(value));
179
+ }
150
180
  var MetadataClient = class _MetadataClient {
151
181
  constructor(config) {
152
182
  __publicField(this, "base");
@@ -197,34 +227,102 @@ var MetadataClient = class _MetadataClient {
197
227
  ...this.onSaveAdvisory ? { onSaveAdvisory: this.onSaveAdvisory } : {}
198
228
  });
199
229
  }
230
+ /**
231
+ * Share one in-flight GET among the callers asking the same method for the
232
+ * same URL with the same headers through the same transport (objectui#11797).
233
+ *
234
+ * Opening a Studio package mounts several readers that ask the same
235
+ * question at once, and each one sent its own request. The first call for a
236
+ * key sends `read()`; a call that arrives while it is pending waits for that
237
+ * request instead of sending another.
238
+ *
239
+ * ⛔ Not a response cache, deliberately — the rules `findOne` keeps on the
240
+ * data adapter (objectui#11699):
241
+ * - the entry goes the moment the read settles, so a call made after an
242
+ * answer always reaches the network (there is no reuse window: a client
243
+ * cannot see every write that changes metadata, an agent's server-side
244
+ * edits among them, and a window would answer the read after such a write
245
+ * with the state from before it);
246
+ * - a rejection reaches every caller already waiting and is not remembered;
247
+ * - a write sent through the same transport drops every pending read when
248
+ * it lands (see {@link sendWrite}), so a read asked after a save or a
249
+ * publish is never answered by a request sent before it;
250
+ * - every caller, the first one included, gets an answer no other caller
251
+ * holds (see {@link handOut}).
252
+ */
253
+ shareRead(reader, url, read) {
254
+ const reads = pendingReadsOn(this.fetchImpl);
255
+ const key = JSON.stringify([reader, url, Object.entries(this.headers).sort(([a], [b]) => a.localeCompare(b))]);
256
+ let entry = reads.get(key);
257
+ if (!entry) {
258
+ const created = { read: read(), waiting: 0 };
259
+ reads.set(key, created);
260
+ const settle = () => {
261
+ if (reads.get(key) === created) reads.delete(key);
262
+ };
263
+ created.read.then(settle, settle);
264
+ entry = created;
265
+ }
266
+ return handOut(entry);
267
+ }
268
+ /**
269
+ * Send a write, then drop every read still pending on this transport
270
+ * (objectui#11797): a read on the wire when the write lands was asked before
271
+ * it, so a caller asking after the write must not join it. Dropped however
272
+ * the write ends — a refused or interrupted write may still have changed
273
+ * something. The callers already holding a pending read keep its answer.
274
+ */
275
+ async sendWrite(url, init) {
276
+ try {
277
+ return await this.fetchImpl(url, init);
278
+ } finally {
279
+ pendingReads.get(this.fetchImpl)?.clear();
280
+ }
281
+ }
200
282
  /** List all registered metadata types (returns the registry rows). */
201
283
  async listTypes() {
202
- const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
203
- if (!res.ok) throw await parseError(res);
204
- const data = await res.json();
205
- if (Array.isArray(data)) return data;
206
- if (data && Array.isArray(data.entries)) return data.entries;
207
- if (data && Array.isArray(data.items)) return data.items;
208
- if (data && Array.isArray(data.types)) {
209
- return data.types.map((t) => ({ type: t }));
210
- }
211
- return [];
284
+ return this.shareRead("listTypes", this.base, async () => {
285
+ const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
286
+ if (!res.ok) throw await parseError(res);
287
+ const data = await res.json();
288
+ if (Array.isArray(data)) return data;
289
+ if (data && Array.isArray(data.entries)) return data.entries;
290
+ if (data && Array.isArray(data.items)) return data.items;
291
+ if (data && Array.isArray(data.types)) {
292
+ return data.types.map((t) => ({ type: t }));
293
+ }
294
+ return [];
295
+ });
212
296
  }
213
297
  /** List items of a metadata type (e.g. `object`, `field`, `view`). */
214
298
  async list(type, options = {}) {
299
+ return (await this.listResponse(type, options)).items;
300
+ }
301
+ /**
302
+ * The same list read as {@link list}, with what the server serves beside
303
+ * the items (objectui#12349): today that is the automation engine's
304
+ * `registrations` on a `flow` list — see {@link MetadataListAnswer}.
305
+ *
306
+ * One request for both methods: `list` is this read with the items picked
307
+ * out, so a caller asking both at once shares one in-flight GET.
308
+ */
309
+ async listResponse(type, options = {}) {
215
310
  const params = [];
216
311
  if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
217
312
  if (this.previewDrafts) params.push("preview=draft");
218
313
  const qs = params.length ? `?${params.join("&")}` : "";
219
314
  const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
220
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
221
- if (!res.ok) throw await parseError(res);
222
- const data = await res.json();
223
- if (Array.isArray(data)) return data;
224
- if (data && Array.isArray(data.items)) {
225
- return data.items;
226
- }
227
- return [];
315
+ return this.shareRead("list", url, async () => {
316
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
317
+ if (!res.ok) throw await parseError(res);
318
+ const data = await res.json();
319
+ if (Array.isArray(data)) return { items: data };
320
+ if (data && Array.isArray(data.items)) {
321
+ const registrations = readRegistrations(data.registrations);
322
+ return { items: data.items, ...registrations ? { registrations } : {} };
323
+ }
324
+ return { items: [] };
325
+ });
228
326
  }
229
327
  /**
230
328
  * List pending DRAFT items (ADR-0033) — what an AI authored but nobody
@@ -240,11 +338,13 @@ var MetadataClient = class _MetadataClient {
240
338
  if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
241
339
  const qs = params.length ? `?${params.join("&")}` : "";
242
340
  const url = `${this.base}/_drafts${qs}`;
243
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
244
- if (!res.ok) throw await parseError(res);
245
- const data = await res.json();
246
- if (Array.isArray(data)) return data;
247
- return data?.drafts ?? data?.data?.drafts ?? [];
341
+ return this.shareRead("listDrafts", url, async () => {
342
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
343
+ if (!res.ok) throw await parseError(res);
344
+ const data = await res.json();
345
+ if (Array.isArray(data)) return data;
346
+ return data?.drafts ?? data?.data?.drafts ?? [];
347
+ });
248
348
  }
249
349
  /**
250
350
  * Fetch a single metadata item and return the response body EXACTLY as the
@@ -263,10 +363,12 @@ var MetadataClient = class _MetadataClient {
263
363
  if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
264
364
  const qs = params.length ? `?${params.join("&")}` : "";
265
365
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
266
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
267
- if (res.status === 404) return null;
268
- if (!res.ok) throw await parseError(res);
269
- return await res.json();
366
+ return this.shareRead("readItemResponse", url, async () => {
367
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
368
+ if (res.status === 404) return null;
369
+ if (!res.ok) throw await parseError(res);
370
+ return await res.json();
371
+ });
270
372
  }
271
373
  /**
272
374
  * Get a single metadata item. Returns the unwrapped item CONTENT — the
@@ -322,13 +424,51 @@ var MetadataClient = class _MetadataClient {
322
424
  * (app-shell) and `unwrapViewDraft` (this package) read a draft that arrived
323
425
  * through {@link get}, which already unwrapped the envelope. They split on
324
426
  * which method feeds them, not on what they mean — all three strip.
427
+ *
428
+ * objectui#11799 — whether a draft exists is answered by the pending-drafts
429
+ * ledger first (see {@link draftLedgerMayHold}), and `?state=draft` is sent
430
+ * only when the ledger lists the name. The item read answers 404 when there
431
+ * is no draft, so opening every item in Studio used to log one error per
432
+ * item for an answer the ledger gives with a 200.
325
433
  */
326
434
  async getDraft(type, name, options = {}) {
435
+ if (!await this.draftLedgerMayHold(name)) return null;
327
436
  return this.readItemResponse(type, name, {
328
437
  state: "draft",
329
438
  ...options.packageId ? { packageId: options.packageId } : {}
330
439
  });
331
440
  }
441
+ /**
442
+ * Whether the pending-drafts ledger (`GET /meta/_drafts`) may hold a draft
443
+ * named `name` (objectui#11799).
444
+ *
445
+ * Read through {@link listDrafts}, the shared read objectui#11797 made: a
446
+ * question asked while a ledger read is pending waits for it, and a write
447
+ * through this transport drops it when the write lands ({@link sendWrite}),
448
+ * so a draft saved a moment ago is never reported absent by a read sent
449
+ * before the save.
450
+ *
451
+ * The WHOLE ledger, matched by name. The `?state=draft` read finds a row
452
+ * under either spelling of the type, and, when it names a package, that
453
+ * package's row or the package-less one. The ledger's `type` and `packageId`
454
+ * filters are exact, so a filtered ledger could miss a draft the item read
455
+ * would serve, and the editor would open the published body over a pending
456
+ * draft. Unfiltered, every miss here is a real absence; a same-name row of
457
+ * another type or package only costs the item read that was always sent.
458
+ *
459
+ * A ledger that cannot be read (403 without an authoring capability, 501 on
460
+ * a kernel without drafts, a network fault) is UNKNOWN, not empty: the item
461
+ * read is sent, as before.
462
+ */
463
+ async draftLedgerMayHold(name) {
464
+ let drafts;
465
+ try {
466
+ drafts = await this.listDrafts();
467
+ } catch {
468
+ return true;
469
+ }
470
+ return drafts.some((d) => d?.name === name);
471
+ }
332
472
  /**
333
473
  * Report the runtime authoring gate's advisory findings for a write the
334
474
  * server has ALREADY committed (#4133 for the save door, #5026 for the
@@ -366,11 +506,17 @@ var MetadataClient = class _MetadataClient {
366
506
  `MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
367
507
  );
368
508
  }
509
+ if (options.ifMatch && options.ifNoneMatch) {
510
+ throw new Error(
511
+ `MetadataClient.save: ifMatch and ifNoneMatch are mutually exclusive (type="${type}", name="${name}"). Send ifMatch with the version you read, or ifNoneMatch: "*" when no row is there.`
512
+ );
513
+ }
369
514
  assertObjectMetadataWritable(type, item, "MetadataClient.save");
370
515
  const params = [];
371
516
  if (options.force) params.push("force=true");
372
517
  if (options.mode === "draft") params.push("mode=draft");
373
518
  if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
519
+ if (options.surface) params.push(`surface=${encodeURIComponent(options.surface)}`);
374
520
  const qs = params.length ? `?${params.join("&")}` : "";
375
521
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
376
522
  const headers = {
@@ -378,7 +524,8 @@ var MetadataClient = class _MetadataClient {
378
524
  "Content-Type": "application/json"
379
525
  };
380
526
  if (options.ifMatch) headers["If-Match"] = options.ifMatch;
381
- const res = await this.fetchImpl(url, {
527
+ if (options.ifNoneMatch) headers["If-None-Match"] = options.ifNoneMatch;
528
+ const res = await this.sendWrite(url, {
382
529
  method: "PUT",
383
530
  headers,
384
531
  body: JSON.stringify(item)
@@ -459,17 +606,40 @@ var MetadataClient = class _MetadataClient {
459
606
  * {@link listDrafts} reads `data?.data?.drafts` because `GET /meta/_drafts`
460
607
  * IS a dispatcher route, and `publishHealthFromResponse` unwraps because the
461
608
  * batch door really is enveloped. The rule is per-route, not per-file.
609
+ *
610
+ * ## The bundle member (objectui#12402)
611
+ *
612
+ * Pass `member` to publish one member of a type whose identity is a pair
613
+ * (today only `email_template`, `(name, locale)`), sent as `?member=` — the
614
+ * selector the by-name doors read since objectstack `038d4c958`, and the
615
+ * value a `GET /meta/_drafts` row carries as `member`. A publish that names
616
+ * none promotes the CANONICAL member's draft, so two languages of one
617
+ * template drafted outside a package cannot both be published by name
618
+ * without it. The parameter is OMITTED, never sent empty, when the caller
619
+ * has no member: the door refuses a blank selector, and refuses any
620
+ * selector on a type with no members.
462
621
  */
463
- async publishDraft(type, name) {
464
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
465
- const res = await this.fetchImpl(url, {
622
+ async publishDraft(type, name, options = {}) {
623
+ const params = [];
624
+ if (options.member) params.push(`member=${encodeURIComponent(options.member)}`);
625
+ const qs = params.length ? `?${params.join("&")}` : "";
626
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish${qs}`;
627
+ const res = await this.sendWrite(url, {
466
628
  method: "POST",
467
629
  headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
468
630
  body: "{}"
469
631
  });
470
632
  if (!res.ok) throw await parseError(res);
471
633
  const body = await res.json().catch(() => ({}));
472
- this.emitAdvisories(body, { type, name, door: "publish", mode: "publish" });
634
+ this.emitAdvisories(body, {
635
+ type,
636
+ name,
637
+ // objectui#12413 — the member this publish named, under the same rule as
638
+ // the query parameter above: present only when the caller had one.
639
+ ...options.member ? { member: options.member } : {},
640
+ door: "publish",
641
+ mode: "publish"
642
+ });
473
643
  return body;
474
644
  }
475
645
  /**
@@ -517,22 +687,32 @@ var MetadataClient = class _MetadataClient {
517
687
  * says to read `outcome`, not the boolean), and the two callers have their
518
688
  * own, different rules for the refusal and rolled-back cases. Deciding that
519
689
  * here would change behaviour this card is not about.
690
+ *
691
+ * ## The opt-in backfill (objectui#12929)
692
+ *
693
+ * `options.backfill` is sent as the body's `backfill` key, as given, and only
694
+ * when the caller passes one ({@link MetadataPublishPackageDraftsOptions}).
695
+ * Without it the body is `{}`, byte for byte what every caller sent before,
696
+ * so a caller that names nothing writes nothing into existing rows. The
697
+ * receipt is `backfillApplied` on the returned body, unexamined like the rest
698
+ * of a 2xx: one element per named field with the rows written, and `error`
699
+ * on a field that stopped short (the publish itself stands).
520
700
  */
521
- async publishPackageDrafts(packageId) {
701
+ async publishPackageDrafts(packageId, options = {}) {
522
702
  if (!packageId || !String(packageId).trim()) {
523
703
  throw new Error(
524
704
  "MetadataClient.publishPackageDrafts: packageId must be non-empty. The POST /packages/:id/publish-drafts route requires an id segment."
525
705
  );
526
706
  }
527
707
  const url = `${packagesBaseOf(this.base)}/${encodeURIComponent(packageId)}/publish-drafts`;
528
- const res = await this.fetchImpl(url, {
708
+ const res = await this.sendWrite(url, {
529
709
  method: "POST",
530
710
  // Both call sites this replaces sent the session cookie, and the client's
531
711
  // own fetch adds the console's Bearer token — so the routed call carries a
532
712
  // superset of what the bare doors carried, never less.
533
713
  credentials: "include",
534
714
  headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
535
- body: "{}"
715
+ body: JSON.stringify(options.backfill !== void 0 ? { backfill: options.backfill } : {})
536
716
  });
537
717
  if (!res.ok) throw await parseError(res);
538
718
  const body = unwrapDispatcherEnvelope(await res.json().catch(() => ({})));
@@ -682,13 +862,22 @@ var MetadataClient = class _MetadataClient {
682
862
  * Idempotent: returns the result even when no overlay row existed.
683
863
  * Pass `state: 'draft'` to discard the pending draft only (keeps the
684
864
  * published overlay intact) — useful for a Studio "Discard draft" button.
865
+ *
866
+ * Pass `packageId` to name the package whose row the delete removes, sent
867
+ * as `?package=`. A delete that names none addresses the one package that
868
+ * holds the item; when two or more packages hold it the door refuses it
869
+ * `409 RESOURCE_CONFLICT`, deletes nothing, and names `?package=` as the
870
+ * remedy. That refusal is thrown like any other {@link MetadataError}.
685
871
  */
686
872
  async reset(type, name, options = {}) {
687
- const qs = options.state === "draft" ? "?state=draft" : "";
873
+ const params = [];
874
+ if (options.state === "draft") params.push("state=draft");
875
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
876
+ const qs = params.length ? `?${params.join("&")}` : "";
688
877
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
689
878
  const headers = { ...this.headers };
690
879
  if (options.ifMatch) headers["If-Match"] = options.ifMatch;
691
- const res = await this.fetchImpl(url, { method: "DELETE", headers });
880
+ const res = await this.sendWrite(url, { method: "DELETE", headers });
692
881
  if (!res.ok) throw await parseError(res);
693
882
  return await res.json();
694
883
  }
@@ -712,7 +901,7 @@ var MetadataClient = class _MetadataClient {
712
901
  ...this.headers,
713
902
  "Content-Type": "application/json"
714
903
  };
715
- const res = await this.fetchImpl(url, {
904
+ const res = await this.sendWrite(url, {
716
905
  method: "POST",
717
906
  headers,
718
907
  body: JSON.stringify(options.message ? { message: options.message } : {})
@@ -736,7 +925,7 @@ var MetadataClient = class _MetadataClient {
736
925
  ...this.headers,
737
926
  "Content-Type": "application/json"
738
927
  };
739
- const res = await this.fetchImpl(url, {
928
+ const res = await this.sendWrite(url, {
740
929
  method: "POST",
741
930
  headers,
742
931
  body: JSON.stringify({
@@ -1361,21 +1550,54 @@ var MetadataCache = class {
1361
1550
  this.cache.delete(key);
1362
1551
  }
1363
1552
  }
1553
+ return this.load(key, fetcher);
1554
+ }
1555
+ /**
1556
+ * Join the read in flight for `key`, or start one, and never answer from the
1557
+ * settled entry: a caller of this always gets an answer from a request that
1558
+ * was on the wire when it asked.
1559
+ *
1560
+ * objectui#13044. This is the door for a caller whose own contract is "ask
1561
+ * the server", which should still not put a second identical request on the
1562
+ * wire while one is already there.
1563
+ *
1564
+ * objectui#13057. The landing is stored, exactly as one {@link get} started
1565
+ * would store it, so a later {@link get} for `key` may be answered from it.
1566
+ * The caller asked the server; a reader that may be served what the cache
1567
+ * already holds is served the answer that caller just received.
1568
+ */
1569
+ share(key, fetcher) {
1570
+ return this.load(key, fetcher);
1571
+ }
1572
+ /**
1573
+ * The in-flight half of {@link get} and {@link share}: join the request in
1574
+ * flight for `key`, or start one.
1575
+ *
1576
+ * A landing is stored, and its in-flight entry cleared, only
1577
+ * while it is still the request registered for `key` (objectui#13044). {@link invalidate}
1578
+ * and {@link clear} detach the request in flight, so a request sent before an
1579
+ * invalidation can neither answer a caller who asks after it nor write its
1580
+ * answer over a newer one.
1581
+ */
1582
+ load(key, fetcher) {
1364
1583
  const existing = this.inflight.get(key);
1365
1584
  if (existing) {
1366
1585
  this.stats.coalesced++;
1367
1586
  return existing;
1368
1587
  }
1369
1588
  this.stats.misses++;
1370
- const promise = (async () => {
1589
+ const self = {};
1590
+ const run = async () => {
1371
1591
  try {
1372
1592
  const data = await fetcher();
1373
- this.set(key, data);
1593
+ if (this.inflight.get(key) === self.promise) this.set(key, data);
1374
1594
  return data;
1375
1595
  } finally {
1376
- this.inflight.delete(key);
1596
+ if (this.inflight.get(key) === self.promise) this.inflight.delete(key);
1377
1597
  }
1378
- })();
1598
+ };
1599
+ const promise = run();
1600
+ self.promise = promise;
1379
1601
  this.inflight.set(key, promise);
1380
1602
  return promise;
1381
1603
  }
@@ -1423,8 +1645,10 @@ var MetadataCache = class {
1423
1645
  invalidate(key) {
1424
1646
  if (key) {
1425
1647
  this.cache.delete(key);
1648
+ this.inflight.delete(key);
1426
1649
  } else {
1427
1650
  this.cache.clear();
1651
+ this.inflight.clear();
1428
1652
  }
1429
1653
  }
1430
1654
  /**
@@ -1494,6 +1718,21 @@ var MetadataCache = class {
1494
1718
  }
1495
1719
  };
1496
1720
 
1721
+ // src/metadata-error.ts
1722
+ function extractIssues(e) {
1723
+ const issues = e?.issues;
1724
+ return Array.isArray(issues) ? issues : [];
1725
+ }
1726
+ function formatMetadataIssue(i) {
1727
+ return `\u2022 ${i.path && i.path.length > 0 ? i.path : "(root)"} \u2014 ${i.message}`;
1728
+ }
1729
+ function formatMetadataError(e) {
1730
+ const issues = extractIssues(e);
1731
+ if (issues.length > 0) return issues.map(formatMetadataIssue).join("\n");
1732
+ const err = e;
1733
+ return err?.message ?? String(e);
1734
+ }
1735
+
1497
1736
  // src/errors.ts
1498
1737
  var ObjectStackError = class extends Error {
1499
1738
  /**
@@ -1702,51 +1941,67 @@ function present(value) {
1702
1941
 
1703
1942
  // src/userState.ts
1704
1943
  var DEFAULT_RESOURCE = "sys_user_preference";
1705
- function createObjectStackUserStateAdapter(options) {
1706
- const {
1707
- dataSource,
1708
- userId,
1709
- key,
1710
- resource = DEFAULT_RESOURCE,
1711
- onError = () => {
1712
- }
1713
- } = options;
1944
+ var PREFERENCE_FAMILY_ROW_LIMIT = 200;
1945
+ function preferenceQuery(userId, keys, prefixes = []) {
1946
+ if (prefixes.length === 0) {
1947
+ if (keys.length === 1) {
1948
+ return { $filter: { user_id: userId, key: keys[0] }, $top: 1 };
1949
+ }
1950
+ return { $filter: { user_id: userId, key: { $in: [...keys] } }, $top: keys.length };
1951
+ }
1952
+ const arms = [];
1953
+ if (keys.length === 1) arms.push({ key: keys[0] });
1954
+ else if (keys.length > 1) arms.push({ key: { $in: [...keys] } });
1955
+ for (const prefix of prefixes) arms.push({ key: { $startsWith: prefix } });
1956
+ return {
1957
+ $filter: { user_id: userId, ...arms.length === 1 ? arms[0] : { $or: arms } },
1958
+ $top: keys.length + prefixes.length * PREFERENCE_FAMILY_ROW_LIMIT
1959
+ };
1960
+ }
1961
+ var openReads = /* @__PURE__ */ new WeakMap();
1962
+ function joinSharedRead(dataSource, resource, userId, want) {
1963
+ let scopes = openReads.get(dataSource);
1964
+ if (!scopes) {
1965
+ scopes = /* @__PURE__ */ new Map();
1966
+ openReads.set(dataSource, scopes);
1967
+ }
1968
+ const scope = JSON.stringify([resource, userId]);
1969
+ const open = scopes.get(scope);
1970
+ if (open) {
1971
+ if ("key" in want) open.keys.add(want.key);
1972
+ else open.prefixes.add(want.prefix);
1973
+ return open;
1974
+ }
1975
+ const keys = new Set("key" in want ? [want.key] : []);
1976
+ const prefixes = new Set("prefix" in want ? [want.prefix] : []);
1977
+ const opened = scopes;
1978
+ const read = {
1979
+ keys,
1980
+ prefixes,
1981
+ answer: Promise.resolve().then(async () => {
1982
+ if (opened.get(scope) === read) opened.delete(scope);
1983
+ const query = preferenceQuery(userId, [...keys], [...prefixes]);
1984
+ const result = await dataSource.find(resource, query);
1985
+ const rows = result?.data ?? [];
1986
+ return {
1987
+ rows,
1988
+ saturated: prefixes.size > 0 && rows.length >= (query.$top ?? Infinity)
1989
+ };
1990
+ })
1991
+ };
1992
+ scopes.set(scope, read);
1993
+ return read;
1994
+ }
1995
+ function createPreferenceRowWriter(dataSource, resource, userId, key, onError) {
1714
1996
  let cachedRowId = null;
1715
- let saveChain = Promise.resolve();
1716
1997
  const findExisting = async () => {
1717
- const params = {
1718
- $filter: {
1719
- user_id: userId,
1720
- key
1721
- },
1722
- $top: 1
1723
- };
1724
- const result = await dataSource.find(resource, params);
1725
- const rows = result?.data ?? [];
1726
- const match = rows.find((r) => {
1727
- if (!r) return false;
1728
- const userMismatch = r.user_id !== void 0 && r.user_id !== userId;
1729
- const keyMismatch = r.key !== void 0 && r.key !== key;
1730
- return !userMismatch && !keyMismatch;
1731
- });
1732
- return match ?? null;
1998
+ const result = await dataSource.find(resource, preferenceQuery(userId, [key]));
1999
+ return matchPreferenceRow(result?.data ?? [], userId, key);
1733
2000
  };
1734
- const decodeValue = (value) => {
1735
- if (Array.isArray(value)) return value;
1736
- if (typeof value === "string") {
1737
- try {
1738
- const parsed = JSON.parse(value);
1739
- return Array.isArray(parsed) ? parsed : [];
1740
- } catch {
1741
- return [];
1742
- }
1743
- }
1744
- return [];
1745
- };
1746
- const upsert = async (items) => {
2001
+ const upsert = async (value) => {
1747
2002
  if (cachedRowId !== null) {
1748
2003
  try {
1749
- await dataSource.update(resource, cachedRowId, { value: items });
2004
+ await dataSource.update(resource, cachedRowId, { value });
1750
2005
  return;
1751
2006
  } catch (updateError) {
1752
2007
  cachedRowId = null;
@@ -1756,14 +2011,14 @@ function createObjectStackUserStateAdapter(options) {
1756
2011
  const existing = await findExisting();
1757
2012
  if (existing && existing.id !== void 0 && existing.id !== null) {
1758
2013
  cachedRowId = existing.id;
1759
- await dataSource.update(resource, existing.id, { value: items });
2014
+ await dataSource.update(resource, existing.id, { value });
1760
2015
  return;
1761
2016
  }
1762
2017
  try {
1763
2018
  const created = await dataSource.create(resource, {
1764
2019
  user_id: userId,
1765
2020
  key,
1766
- value: items
2021
+ value
1767
2022
  });
1768
2023
  const newId = created?.id;
1769
2024
  if (newId !== void 0 && newId !== null) cachedRowId = newId;
@@ -1771,21 +2026,74 @@ function createObjectStackUserStateAdapter(options) {
1771
2026
  const recovered = await findExisting();
1772
2027
  if (recovered && recovered.id !== void 0 && recovered.id !== null) {
1773
2028
  cachedRowId = recovered.id;
1774
- await dataSource.update(resource, recovered.id, { value: items });
2029
+ await dataSource.update(resource, recovered.id, { value });
1775
2030
  return;
1776
2031
  }
1777
2032
  throw createError;
1778
2033
  }
1779
2034
  };
2035
+ return {
2036
+ remember(id) {
2037
+ cachedRowId = id;
2038
+ },
2039
+ upsert,
2040
+ find: findExisting
2041
+ };
2042
+ }
2043
+ function matchPreferenceRow(rows, userId, key) {
2044
+ const match = rows.find((r) => {
2045
+ if (!r) return false;
2046
+ const userMismatch = r.user_id !== void 0 && r.user_id !== userId;
2047
+ const keyMismatch = r.key !== void 0 && r.key !== key;
2048
+ return !userMismatch && !keyMismatch;
2049
+ });
2050
+ return match ?? null;
2051
+ }
2052
+ function createObjectStackUserStateAdapter(options) {
2053
+ const {
2054
+ dataSource,
2055
+ userId,
2056
+ key,
2057
+ resource = DEFAULT_RESOURCE,
2058
+ onError = () => {
2059
+ }
2060
+ } = options;
2061
+ const writer = createPreferenceRowWriter(dataSource, resource, userId, key, onError);
2062
+ let saveChain = Promise.resolve();
2063
+ const loadExisting = async () => {
2064
+ const read = joinSharedRead(dataSource, resource, userId, { key });
2065
+ const { rows, saturated } = await read.answer;
2066
+ if (read.keys.size === 1 && read.prefixes.size === 0) {
2067
+ return matchPreferenceRow(rows, userId, key);
2068
+ }
2069
+ const own = rows.find(
2070
+ (r) => r && r.key === key && (r.user_id === void 0 || r.user_id === userId)
2071
+ );
2072
+ if (own) return own;
2073
+ if (saturated || rows.some((r) => r && r.key === void 0)) return writer.find();
2074
+ return null;
2075
+ };
2076
+ const decodeValue = (value) => {
2077
+ if (Array.isArray(value)) return value;
2078
+ if (typeof value === "string") {
2079
+ try {
2080
+ const parsed = JSON.parse(value);
2081
+ return Array.isArray(parsed) ? parsed : [];
2082
+ } catch {
2083
+ return [];
2084
+ }
2085
+ }
2086
+ return [];
2087
+ };
1780
2088
  return {
1781
2089
  async load() {
1782
2090
  try {
1783
- const row = await findExisting();
2091
+ const row = await loadExisting();
1784
2092
  if (!row) {
1785
- cachedRowId = null;
2093
+ writer.remember(null);
1786
2094
  return [];
1787
2095
  }
1788
- if (row.id !== void 0 && row.id !== null) cachedRowId = row.id;
2096
+ if (row.id !== void 0 && row.id !== null) writer.remember(row.id);
1789
2097
  return decodeValue(row.value);
1790
2098
  } catch (error) {
1791
2099
  onError("load", error);
@@ -1793,7 +2101,7 @@ function createObjectStackUserStateAdapter(options) {
1793
2101
  }
1794
2102
  },
1795
2103
  async save(items) {
1796
- const run = saveChain.then(() => upsert(items)).catch((error) => {
2104
+ const run = saveChain.then(() => writer.upsert(items)).catch((error) => {
1797
2105
  onError("save", error);
1798
2106
  });
1799
2107
  saveChain = run;
@@ -1801,6 +2109,69 @@ function createObjectStackUserStateAdapter(options) {
1801
2109
  }
1802
2110
  };
1803
2111
  }
2112
+ function createObjectStackUserPreferenceFamily(options) {
2113
+ const {
2114
+ dataSource,
2115
+ userId,
2116
+ prefix,
2117
+ resource = DEFAULT_RESOURCE,
2118
+ onError = () => {
2119
+ }
2120
+ } = options;
2121
+ const writers = /* @__PURE__ */ new Map();
2122
+ const chains = /* @__PURE__ */ new Map();
2123
+ const writerFor = (key) => {
2124
+ let writer = writers.get(key);
2125
+ if (!writer) {
2126
+ writer = createPreferenceRowWriter(dataSource, resource, userId, key, onError);
2127
+ writers.set(key, writer);
2128
+ }
2129
+ return writer;
2130
+ };
2131
+ const decodeValue = (value) => {
2132
+ let parsed = value;
2133
+ if (typeof value === "string") {
2134
+ try {
2135
+ parsed = JSON.parse(value);
2136
+ } catch {
2137
+ return void 0;
2138
+ }
2139
+ }
2140
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
2141
+ };
2142
+ return {
2143
+ prefix,
2144
+ async load() {
2145
+ try {
2146
+ const read = joinSharedRead(dataSource, resource, userId, { prefix });
2147
+ const { rows } = await read.answer;
2148
+ const out = {};
2149
+ for (const row of rows) {
2150
+ if (!row || typeof row.key !== "string" || !row.key.startsWith(prefix)) continue;
2151
+ if (row.user_id !== void 0 && row.user_id !== userId) continue;
2152
+ if (row.id !== void 0 && row.id !== null) writerFor(row.key).remember(row.id);
2153
+ const value = decodeValue(row.value);
2154
+ if (value !== void 0) out[row.key] = value;
2155
+ }
2156
+ return out;
2157
+ } catch (error) {
2158
+ onError("load", error);
2159
+ return {};
2160
+ }
2161
+ },
2162
+ async save(key, value) {
2163
+ if (!key.startsWith(prefix) || key.length === prefix.length) {
2164
+ onError("save", new Error(`key "${key}" is not in the family "${prefix}"`));
2165
+ return;
2166
+ }
2167
+ const run = (chains.get(key) ?? Promise.resolve()).then(() => writerFor(key).upsert(value)).catch((error) => {
2168
+ onError("save", error);
2169
+ });
2170
+ chains.set(key, run);
2171
+ return run;
2172
+ }
2173
+ };
2174
+ }
1804
2175
 
1805
2176
  // src/draft-envelope.ts
1806
2177
  import { stripReadDecorations } from "@objectstack/spec/kernel";
@@ -1813,20 +2184,61 @@ function extractDraftBody(resp) {
1813
2184
  if (Object.keys(body).length === 0) return null;
1814
2185
  return stripReadDecorations(body);
1815
2186
  }
1816
-
1817
- // src/metadata-error.ts
1818
- function extractIssues(e) {
1819
- const issues = e?.issues;
1820
- return Array.isArray(issues) ? issues : [];
2187
+ function readDraftChanges(resp) {
2188
+ if (!resp || typeof resp !== "object") return null;
2189
+ const changes = resp.draftChanges;
2190
+ return Array.isArray(changes) ? changes : null;
1821
2191
  }
1822
- function formatMetadataIssue(i) {
1823
- return `\u2022 ${i.path && i.path.length > 0 ? i.path : "(root)"} \u2014 ${i.message}`;
2192
+ function isPathPrefix(prefix, path) {
2193
+ if (prefix.length > path.length) return false;
2194
+ for (let i = 0; i < prefix.length; i++) if (prefix[i] !== path[i]) return false;
2195
+ return true;
1824
2196
  }
1825
- function formatMetadataError(e) {
1826
- const issues = extractIssues(e);
1827
- if (issues.length > 0) return issues.map(formatMetadataIssue).join("\n");
1828
- const err = e;
1829
- return err?.message ?? String(e);
2197
+ function governingDraftChange(changes, path) {
2198
+ let best = null;
2199
+ for (const change of changes ?? []) {
2200
+ if (!isPathPrefix(change.path, path)) continue;
2201
+ if (!best || change.path.length > best.path.length) best = change;
2202
+ }
2203
+ return best;
2204
+ }
2205
+ function draftChangesUnder(changes, path) {
2206
+ return (changes ?? []).filter((change) => change.path.length > path.length && isPathPrefix(path, change.path));
2207
+ }
2208
+
2209
+ // src/picklist-binding.ts
2210
+ function dropServedPicklistOptions(body) {
2211
+ if (!body || typeof body !== "object") return body;
2212
+ const fields = body.fields;
2213
+ if (Array.isArray(fields)) {
2214
+ let changed = false;
2215
+ const next = fields.map((def) => {
2216
+ const stripped = withoutServedOptions(def);
2217
+ if (stripped !== def) changed = true;
2218
+ return stripped;
2219
+ });
2220
+ return changed ? { ...body, fields: next } : body;
2221
+ }
2222
+ if (fields && typeof fields === "object") {
2223
+ let changed = false;
2224
+ const next = Object.fromEntries(
2225
+ Object.entries(fields).map(([name, def]) => {
2226
+ const stripped = withoutServedOptions(def);
2227
+ if (stripped !== def) changed = true;
2228
+ return [name, stripped];
2229
+ })
2230
+ );
2231
+ return changed ? { ...body, fields: next } : body;
2232
+ }
2233
+ return body;
2234
+ }
2235
+ function withoutServedOptions(def) {
2236
+ if (!def || typeof def !== "object" || Array.isArray(def)) return def;
2237
+ const record = def;
2238
+ if (record.picklist === void 0) return def;
2239
+ if (!Object.prototype.hasOwnProperty.call(record, "options")) return def;
2240
+ const { options: _served, ...authored } = record;
2241
+ return authored;
1830
2242
  }
1831
2243
 
1832
2244
  // src/index.ts
@@ -2104,6 +2516,7 @@ function serializeOrderBy(orderby) {
2104
2516
  }
2105
2517
  return void 0;
2106
2518
  }
2519
+ var VIEW_LIST_KEY = "list:view";
2107
2520
  var discoveryCache = /* @__PURE__ */ new Map();
2108
2521
  async function getSharedDiscovery(baseUrl, fetcher) {
2109
2522
  const key = baseUrl || "<default>";
@@ -2157,29 +2570,6 @@ function metaEnvelopeCarriesItem(envelope) {
2157
2570
  if (envelope == null || typeof envelope !== "object") return false;
2158
2571
  return envelope.item != null;
2159
2572
  }
2160
- var VIEW_CONFIG_CAPABILITY = "manage_metadata";
2161
- var ViewConfigPermissionDeniedError = class extends Error {
2162
- constructor(objectName, viewId) {
2163
- super(
2164
- `View configuration is shared: changing "${viewId}" changes it for everyone who uses this view. That requires the "${VIEW_CONFIG_CAPABILITY}" capability, which this session does not hold.`
2165
- );
2166
- __publicField(this, "code", "VIEW_CONFIG_PERMISSION_DENIED");
2167
- /** The capability that was required and not held. */
2168
- __publicField(this, "capability", VIEW_CONFIG_CAPABILITY);
2169
- /** The object whose view configuration the refused write targeted. */
2170
- __publicField(this, "objectName");
2171
- /** The view id the refused write targeted. */
2172
- __publicField(this, "viewId");
2173
- this.name = "ViewConfigPermissionDeniedError";
2174
- this.objectName = objectName;
2175
- this.viewId = viewId;
2176
- }
2177
- };
2178
- function isViewConfigPermissionDeniedError(error) {
2179
- if (!error || typeof error !== "object") return false;
2180
- const e = error;
2181
- return e.code === "VIEW_CONFIG_PERMISSION_DENIED" || e.name === "ViewConfigPermissionDeniedError";
2182
- }
2183
2573
  var AnalyticsNotInstalledError = class extends Error {
2184
2574
  constructor(surface, detail, serverCode) {
2185
2575
  super(
@@ -2545,6 +2935,16 @@ function sameWireValue(a, b) {
2545
2935
  if (aEmpty || bEmpty) return aEmpty && bEmpty;
2546
2936
  return stableStringify(a) === stableStringify(b);
2547
2937
  }
2938
+ function readWriteWarnings(result) {
2939
+ const raw = result?.warnings;
2940
+ if (!Array.isArray(raw) || raw.length === 0) return [];
2941
+ const out = [];
2942
+ for (const entry of raw) {
2943
+ const parsed = ValidateDataIssueSchema.safeParse(entry);
2944
+ if (parsed.success) out.push(parsed.data);
2945
+ }
2946
+ return out;
2947
+ }
2548
2948
  function withoutNoOpDrops(droppedFields, sent, stored) {
2549
2949
  if (!sent || !stored || typeof sent !== "object" || typeof stored !== "object") {
2550
2950
  return droppedFields;
@@ -2625,6 +3025,10 @@ function mergeViewPatch(current, partial, viewName, objectName) {
2625
3025
  object: current?.object || current?.data?.object || objectName
2626
3026
  };
2627
3027
  }
3028
+ function isCallerAbort(error, init) {
3029
+ if (init?.signal?.aborted) return true;
3030
+ return error?.name === "AbortError";
3031
+ }
2628
3032
  var ObjectStackAdapter = class {
2629
3033
  constructor(config) {
2630
3034
  __publicField(this, "client");
@@ -2648,6 +3052,13 @@ var ObjectStackAdapter = class {
2648
3052
  // multiple sibling components requesting the same dataset on first paint)
2649
3053
  // into a single network round trip.
2650
3054
  __publicField(this, "inflightFinds", /* @__PURE__ */ new Map());
3055
+ // In-flight findOne() reads keyed by resource + record id + serialized
3056
+ // params, shared the same way (objectui#11699). Opening one record page
3057
+ // asked for the same record twice at once: `record:details`' DetailView
3058
+ // re-runs its load effect while its first read is still on the wire. A
3059
+ // write to a resource drops that resource's entries (see `emitMutation`),
3060
+ // so a read asked after a save is never answered by one sent before it.
3061
+ __publicField(this, "inflightFindOnes", /* @__PURE__ */ new Map());
2651
3062
  // Resources that have responded 404 at least once (collection not installed
2652
3063
  // on this backend). Subsequent find() calls short-circuit to an empty result
2653
3064
  // so optional collections like sys_presence don't hammer the server with
@@ -2690,68 +3101,78 @@ var ObjectStackAdapter = class {
2690
3101
  // the opposite direction — those two describe a write that succeeded, this
2691
3102
  // one a read that did not happen.
2692
3103
  __publicField(this, "metadataReadWarningListeners", /* @__PURE__ */ new Set());
2693
- // [ADR-0066] The session's REPORTED system capabilities, pushed in by the
2694
- // host (see `setSystemCapabilities`). `undefined` means NO answer was ever
2695
- // reported — which is NOT the same as a reported-empty grant, and the two
2696
- // are treated differently by `maySetViewConfig` below.
2697
- __publicField(this, "systemCapabilities");
2698
- this.client = new ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
3104
+ const transport = config.fetch || globalThis.fetch.bind(globalThis);
3105
+ this.fetchImpl = (input, init) => this.observeServerAnswer(() => transport(input, init), init);
3106
+ this.client = new ObjectStackClient({
3107
+ ...config,
3108
+ fetch: this.fetchImpl,
3109
+ logger: createQuietHttpLogger()
3110
+ });
2699
3111
  this.installSaveAdvisoryInterceptor();
2700
3112
  this.metadataCache = new MetadataCache(config.cache);
3113
+ this.installViewListShare();
2701
3114
  this.autoReconnect = config.autoReconnect ?? true;
2702
3115
  this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3;
2703
3116
  this.reconnectDelay = config.reconnectDelay ?? 1e3;
2704
3117
  this.baseUrl = config.baseUrl;
2705
3118
  this.token = config.token;
2706
- this.systemCapabilities = config.systemCapabilities;
2707
- this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
2708
3119
  }
2709
3120
  /**
2710
3121
  * Ensure the client is connected to the server.
2711
3122
  * Call this before making requests or it will auto-connect on first request.
3123
+ *
3124
+ * With `autoReconnect`, a failed discovery read is retried up to
3125
+ * `maxReconnectAttempts` times with exponential backoff. The promise resolves
3126
+ * once discovery answers and rejects with the last `ConnectionError` once the
3127
+ * retries are spent (objectui#12318).
2712
3128
  */
2713
3129
  async connect() {
2714
3130
  if (this.connected) return;
2715
3131
  if (this.connectPromise) return this.connectPromise;
2716
3132
  this.setConnectionState("connecting");
2717
- this.connectPromise = (async () => {
2718
- try {
2719
- const baseUrl = this.baseUrl || "";
2720
- const discoveryUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/api/v1/discovery` : "/api/v1/discovery";
2721
- const data = await getSharedDiscovery(baseUrl, async () => {
2722
- const res = await this.fetchImpl(discoveryUrl, {
2723
- method: "GET",
2724
- headers: this.token ? { Authorization: `Bearer ${this.token}` } : void 0
2725
- });
2726
- if (!res.ok) {
2727
- throw new Error(`discovery ${res.status} ${res.statusText}`);
2728
- }
2729
- const body = await res.json();
2730
- return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
3133
+ this.connectPromise = this.attemptConnect().finally(() => {
3134
+ this.connectPromise = null;
3135
+ });
3136
+ return this.connectPromise;
3137
+ }
3138
+ /**
3139
+ * One discovery attempt, and the retry that follows it when it fails with
3140
+ * budget left. Runs inside the promise {@link connect} hands every caller.
3141
+ */
3142
+ async attemptConnect() {
3143
+ try {
3144
+ const baseUrl = this.baseUrl || "";
3145
+ const discoveryUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/api/v1/discovery` : "/api/v1/discovery";
3146
+ const data = await getSharedDiscovery(baseUrl, async () => {
3147
+ const res = await this.fetchImpl(discoveryUrl, {
3148
+ method: "GET",
3149
+ headers: this.token ? { Authorization: `Bearer ${this.token}` } : void 0
2731
3150
  });
2732
- this.client.discoveryInfo = data;
2733
- this.atomicBatchCapability = readTransactionalBatchCapability(data);
2734
- this.connected = true;
2735
- this.reconnectAttempts = 0;
2736
- this.setConnectionState("connected");
2737
- } catch (error) {
2738
- const errorMessage = error instanceof Error ? error.message : "Failed to connect to ObjectStack server";
2739
- const connectionError = new ConnectionError(
2740
- errorMessage,
2741
- void 0,
2742
- { originalError: error }
2743
- );
2744
- this.setConnectionState("error", connectionError);
2745
- if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
2746
- await this.attemptReconnect();
2747
- } else {
2748
- throw connectionError;
3151
+ if (!res.ok) {
3152
+ throw new Error(`discovery ${res.status} ${res.statusText}`);
2749
3153
  }
2750
- } finally {
2751
- this.connectPromise = null;
3154
+ const body = await res.json();
3155
+ return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
3156
+ });
3157
+ this.client.discoveryInfo = data;
3158
+ this.atomicBatchCapability = readTransactionalBatchCapability(data);
3159
+ this.connected = true;
3160
+ this.reconnectAttempts = 0;
3161
+ this.setConnectionState("connected");
3162
+ } catch (error) {
3163
+ const errorMessage = error instanceof Error ? error.message : "Failed to connect to ObjectStack server";
3164
+ const connectionError = new ConnectionError(
3165
+ errorMessage,
3166
+ void 0,
3167
+ { originalError: error }
3168
+ );
3169
+ this.setConnectionState("error", connectionError);
3170
+ if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
3171
+ await this.attemptReconnect();
3172
+ } else {
3173
+ throw connectionError;
2752
3174
  }
2753
- })();
2754
- return this.connectPromise;
3175
+ }
2755
3176
  }
2756
3177
  /**
2757
3178
  * Attempt to reconnect to the server with exponential backoff
@@ -2761,8 +3182,7 @@ var ObjectStackAdapter = class {
2761
3182
  this.setConnectionState("reconnecting");
2762
3183
  const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
2763
3184
  await new Promise((resolve) => setTimeout(resolve, delay));
2764
- this.connected = false;
2765
- await this.connect();
3185
+ await this.attemptConnect();
2766
3186
  }
2767
3187
  /**
2768
3188
  * Get the current connection state
@@ -2818,6 +3238,63 @@ var ObjectStackAdapter = class {
2818
3238
  }
2819
3239
  });
2820
3240
  }
3241
+ /**
3242
+ * Run one request through the adapter's fetch and let its outcome move the
3243
+ * connection state (objectui#12295). Before the first successful
3244
+ * {@link connect} this records nothing: `connect()` owns the state until
3245
+ * then, and its own discovery read passes through here too.
3246
+ *
3247
+ * - **Answered** — the fetch resolved with a Response, whatever its status.
3248
+ * A 4xx or 5xx is an answer: the server is reachable, so it counts the
3249
+ * same as a 200 and ends any outage.
3250
+ * - **Not answered** — the fetch rejected: no HTTP answer at all (refused,
3251
+ * reset, DNS, an offline browser). A rejection the caller asked for by
3252
+ * aborting is not evidence about the server and records nothing.
3253
+ *
3254
+ * The rejection or the Response reaches the caller unchanged.
3255
+ */
3256
+ async observeServerAnswer(send, init) {
3257
+ let response;
3258
+ try {
3259
+ response = await send();
3260
+ } catch (error) {
3261
+ if (!isCallerAbort(error, init)) this.noteServerUnanswered(error);
3262
+ throw error;
3263
+ }
3264
+ this.noteServerAnswered();
3265
+ return response;
3266
+ }
3267
+ /** An answered request after the first connect: the server is reachable. */
3268
+ noteServerAnswered() {
3269
+ if (!this.connected) return;
3270
+ this.reconnectAttempts = 0;
3271
+ if (this.connectionState !== "connected") this.setConnectionState("connected");
3272
+ }
3273
+ /**
3274
+ * An unanswered request after the first connect. It spends the adapter's own
3275
+ * retry window — the `autoReconnect` / `maxReconnectAttempts` budget that
3276
+ * {@link connect} grants a failed discovery — counted in the requests the
3277
+ * host already makes rather than in timed probes: while the budget lasts the
3278
+ * state is `reconnecting`, and the first unanswered request past it moves the
3279
+ * state to `error`. No timer and no request of the adapter's own is involved;
3280
+ * the next answered request, whenever it comes, restores `connected`.
3281
+ */
3282
+ noteServerUnanswered(error) {
3283
+ if (!this.connected || this.connectionState === "error") return;
3284
+ if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
3285
+ this.reconnectAttempts++;
3286
+ if (this.connectionState !== "reconnecting") this.setConnectionState("reconnecting");
3287
+ return;
3288
+ }
3289
+ this.setConnectionState(
3290
+ "error",
3291
+ new ConnectionError(
3292
+ error instanceof Error ? error.message : "ObjectStack server did not answer",
3293
+ void 0,
3294
+ { originalError: error }
3295
+ )
3296
+ );
3297
+ }
2821
3298
  /**
2822
3299
  * Emit batch progress event to listeners
2823
3300
  */
@@ -2839,9 +3316,7 @@ var ObjectStackAdapter = class {
2839
3316
  return { data: [], total: 0 };
2840
3317
  }
2841
3318
  const key = `${resource}::${stableStringify(params)}`;
2842
- const existing = this.inflightFinds.get(key);
2843
- if (existing) return existing;
2844
- const promise = (async () => {
3319
+ return this.shareInFlight(this.inflightFinds, key, async () => {
2845
3320
  await this.connect();
2846
3321
  if (params?.$expand && params.$expand.length > 0 || params?.$search != null && String(params.$search).trim() !== "") {
2847
3322
  const result = await this.rawFindWithPopulate(resource, params);
@@ -2858,11 +3333,25 @@ var ObjectStackAdapter = class {
2858
3333
  }
2859
3334
  throw err;
2860
3335
  }
2861
- })();
2862
- this.inflightFinds.set(key, promise);
3336
+ });
3337
+ }
3338
+ /**
3339
+ * Share one in-flight read among concurrent callers asking the same
3340
+ * question: the first call for `key` issues `read()`, every call that
3341
+ * arrives while it is still pending gets that same promise, and the entry is
3342
+ * dropped the moment it settles — so nothing is cached past the round trip,
3343
+ * a rejection reaches every caller that shared it and is not remembered, and
3344
+ * the next call after settle issues a fresh read. Used by `find` and
3345
+ * `findOne` (objectui#11699).
3346
+ */
3347
+ shareInFlight(inflight, key, read) {
3348
+ const existing = inflight.get(key);
3349
+ if (existing) return existing;
3350
+ const promise = read();
3351
+ inflight.set(key, promise);
2863
3352
  const cleanup = () => {
2864
- if (this.inflightFinds.get(key) === promise) {
2865
- this.inflightFinds.delete(key);
3353
+ if (inflight.get(key) === promise) {
3354
+ inflight.delete(key);
2866
3355
  }
2867
3356
  };
2868
3357
  promise.then(cleanup, cleanup);
@@ -2941,8 +3430,16 @@ var ObjectStackAdapter = class {
2941
3430
  }
2942
3431
  /**
2943
3432
  * Find a single record by ID.
3433
+ *
3434
+ * Concurrent calls for the same resource, id and params share one request,
3435
+ * exactly as `find` does (objectui#11699); see {@link shareInFlight}.
2944
3436
  */
2945
3437
  async findOne(resource, id, params) {
3438
+ const key = `${resource}::${JSON.stringify(String(id))}::${stableStringify(params)}`;
3439
+ return this.shareInFlight(this.inflightFindOnes, key, () => this.readOne(resource, id, params));
3440
+ }
3441
+ /** The one round trip behind {@link findOne}, unshared. */
3442
+ async readOne(resource, id, params) {
2946
3443
  await this.connect();
2947
3444
  if (params?.$expand && params.$expand.length > 0) {
2948
3445
  try {
@@ -2982,6 +3479,10 @@ var ObjectStackAdapter = class {
2982
3479
  * mutation or starve the other subscribers, so each is isolated.
2983
3480
  */
2984
3481
  emitMutation(event) {
3482
+ const prefix = `${event.resource}::`;
3483
+ for (const key of Array.from(this.inflightFindOnes.keys())) {
3484
+ if (key.startsWith(prefix)) this.inflightFindOnes.delete(key);
3485
+ }
2985
3486
  for (const listener of this.mutationListeners) {
2986
3487
  try {
2987
3488
  listener(event);
@@ -3026,15 +3527,28 @@ var ObjectStackAdapter = class {
3026
3527
  * reason routed to the skew arm, a non-string field element refused, a missing
3027
3528
  * `object` healed from `resource` — never asserted into the union, and the
3028
3529
  * entry itself never dropped for them (objectui#4934, `f75810e7c`).
3530
+ *
3531
+ * The same answer's `warnings` (objectui#12107) rides this one event: it is
3532
+ * emitted when either list is non-empty, so a write whose only news is an
3533
+ * advisory rule hit still reaches the subscriber, with `droppedFields: []`.
3029
3534
  */
3030
3535
  notifyDroppedFields(operation, resource, result, id, sent) {
3031
3536
  const dropped = result?.droppedFields;
3032
- if (!Array.isArray(dropped) || dropped.length === 0) return;
3033
- const valid = dropped.filter(isWireDroppedFieldsEntry).map((e) => asDroppedFieldsNotice(e, resource));
3034
- const stored = result?.record;
3035
- const droppedFields = withoutNoOpDrops(valid, sent, stored);
3036
- if (droppedFields.length === 0) return;
3037
- this.emitWriteWarning({ operation, resource, ...id !== void 0 ? { id } : {}, droppedFields });
3537
+ let droppedFields = [];
3538
+ if (Array.isArray(dropped) && dropped.length > 0) {
3539
+ const valid = dropped.filter(isWireDroppedFieldsEntry).map((e) => asDroppedFieldsNotice(e, resource));
3540
+ const stored = result?.record;
3541
+ droppedFields = withoutNoOpDrops(valid, sent, stored);
3542
+ }
3543
+ const warnings = readWriteWarnings(result);
3544
+ if (droppedFields.length === 0 && warnings.length === 0) return;
3545
+ this.emitWriteWarning({
3546
+ operation,
3547
+ resource,
3548
+ ...id !== void 0 ? { id } : {},
3549
+ droppedFields,
3550
+ ...warnings.length > 0 ? { warnings } : {}
3551
+ });
3038
3552
  }
3039
3553
  /**
3040
3554
  * Same, for the cross-object transactional batch (objectstack-ai/objectstack#3794). Its
@@ -3172,8 +3686,8 @@ var ObjectStackAdapter = class {
3172
3686
  *
3173
3687
  * `ObjectStackClient.meta.saveItem` is the second client class that writes
3174
3688
  * through `PUT /api/v1/meta/:type/:name`, and every one of its callers reaches
3175
- * it through an adapter this class constructed — the four inside this file
3176
- * (`updateViewConfig`, the two view paths, `updateDashboard`) via
3689
+ * it through an adapter this class constructed — the three inside this file
3690
+ * (the two view paths, `updateDashboard`) via
3177
3691
  * `this.client`, and every caller outside it via {@link getClient}, which
3178
3692
  * hands back this same instance: `MetadataService` (app-shell, five saves),
3179
3693
  * `useNavigationSync`, and plugin-designer's Create/EditAppPage. Wrapping the
@@ -3202,20 +3716,30 @@ var ObjectStackAdapter = class {
3202
3716
  *
3203
3717
  * Drafts are NEVER gated: the framework returns at its D1 early-return
3204
3718
  * (`if (args.state !== 'active') return null`) before running a rule, so a
3205
- * draft save produces no findings to withhold. This client class has no draft
3206
- * door at all to worry about — the SDK's `saveItem(type, name, item)` takes no
3207
- * mode and always writes the active door, which is exactly why the gate DOES
3208
- * run for its callers. `mode` on the emitted event is therefore derived from
3209
- * the response's own `state` rather than from a request-side flag that does
3210
- * not exist here: `'draft'` when the server says the row landed as a draft,
3211
- * `'publish'` otherwise. That keeps the event truthful about which door it
3212
- * came through instead of hard-coding one.
3719
+ * draft save produces no findings to withhold. `mode` on the emitted event is
3720
+ * derived from the response's own `state`, not from the caller's options:
3721
+ * `'draft'` when the server says the row landed as a draft, `'publish'`
3722
+ * otherwise. The server's answer is the one that says which door the write
3723
+ * took, so the event cannot claim a door the request only asked for.
3724
+ *
3725
+ * ## Every argument is forwarded (objectui#12300)
3726
+ *
3727
+ * The SDK's `saveItem(type, name, item, options?)` carries the write's scope
3728
+ * in its fourth argument, `SaveMetaItemOptions`: `packageId` (`?package=`),
3729
+ * `force`, `mode` and `ifMatch`. This wrapper used to name three parameters
3730
+ * and call the original with three, so that bag was dropped before the SDK
3731
+ * saw it. Every caller reaching the SDK through {@link getClient} got a bare
3732
+ * `PUT` whatever it passed, while the declared signature still accepted the
3733
+ * bag, so nothing at the call site could tell. The wrapper now takes and
3734
+ * passes the original's own parameter list, so it forwards whatever the SDK
3735
+ * declares, today's bag and any later member, without naming one.
3213
3736
  */
3214
3737
  installSaveAdvisoryInterceptor() {
3215
3738
  const meta = this.client.meta;
3216
3739
  const original = meta.saveItem.bind(meta);
3217
- meta.saveItem = async (type, name, item) => {
3218
- const result = await original(type, name, item);
3740
+ meta.saveItem = async (...args) => {
3741
+ const [type, name] = args;
3742
+ const result = await original(...args);
3219
3743
  try {
3220
3744
  const advisories = readSaveAdvisories(result);
3221
3745
  if (advisories.length > 0) {
@@ -3238,6 +3762,61 @@ var ObjectStackAdapter = class {
3238
3762
  return result;
3239
3763
  };
3240
3764
  }
3765
+ /**
3766
+ * Route this adapter's client's published view-list read through the one
3767
+ * {@link VIEW_LIST_KEY} (objectui#13044).
3768
+ *
3769
+ * The app-shell `MetadataProvider` reads `client.meta.getItems('view')`
3770
+ * through {@link getClient}; {@link listViews} and {@link listViewOverrides}
3771
+ * read the same `GET /api/v1/meta/view`. Each used to put its own request on
3772
+ * the wire, so one list-page open asked the server three times for one URL.
3773
+ *
3774
+ * A caller of the client keeps its contract, "ask the server": it is never
3775
+ * answered from a settled entry. It joins a request already in flight for
3776
+ * the same URL instead of sending a second one, and its answer is stored
3777
+ * under {@link VIEW_LIST_KEY} (see {@link MetadataCache.share}), which is
3778
+ * what {@link readViewList} answers this adapter's own readers from
3779
+ * (objectui#13057). Only the bare call is routed: any argument after the
3780
+ * type changes the URL, so such a call goes straight to the SDK.
3781
+ *
3782
+ * Every caller gets its own copy of the answer. Before this, every reader
3783
+ * got its own parsed response, so a reader that changed what it was handed
3784
+ * changed nothing anyone else read; a shared answer must not change that.
3785
+ *
3786
+ * Same wrap shape and the same bound as `installSaveAdvisoryInterceptor`:
3787
+ * `meta` is an own property of a client this adapter built and never shares.
3788
+ */
3789
+ installViewListShare() {
3790
+ const meta = this.client.meta;
3791
+ const original = meta.getItems.bind(meta);
3792
+ meta.getItems = ((...args) => {
3793
+ if (args[0] !== "view" || args.length > 1) return original(...args);
3794
+ return this.metadataCache.share(VIEW_LIST_KEY, () => original(...args)).then((answer) => structuredClone(answer));
3795
+ });
3796
+ for (const door of ["saveItem", "deleteItem", "publishItem", "rollbackItem"]) {
3797
+ const write = meta[door];
3798
+ if (typeof write !== "function") continue;
3799
+ const bound = write.bind(meta);
3800
+ meta[door] = (...args) => {
3801
+ if (args[0] !== "view") return bound(...args);
3802
+ return bound(...args).finally(() => this.metadataCache.invalidate?.(VIEW_LIST_KEY));
3803
+ };
3804
+ }
3805
+ }
3806
+ /**
3807
+ * The published view list for this adapter's own readers
3808
+ * ({@link listViews}, {@link listViewOverrides}): the answer stored under
3809
+ * {@link VIEW_LIST_KEY} while it holds one (objectui#13057), else the
3810
+ * client's read, which joins the request in flight for that key or starts
3811
+ * one, and stores its answer (objectui#13044). Always a copy the caller may
3812
+ * keep. Every path that should see a fresh list drops the key first; the
3813
+ * list is in {@link VIEW_LIST_KEY}'s doc.
3814
+ */
3815
+ readViewList() {
3816
+ const settled = this.metadataCache.getCachedSync(VIEW_LIST_KEY);
3817
+ if (settled !== void 0) return Promise.resolve(structuredClone(settled));
3818
+ return this.client.meta.getItems("view");
3819
+ }
3241
3820
  async create(resource, data) {
3242
3821
  await this.connect();
3243
3822
  try {
@@ -3995,7 +4574,6 @@ var ObjectStackAdapter = class {
3995
4574
  () => this.fetchObjectSchemaFresh(objectName)
3996
4575
  );
3997
4576
  normalizeSchemaReferenceKeys(schema);
3998
- this.applyFieldWidgetOverrides(objectName, schema);
3999
4577
  return schema;
4000
4578
  } catch (error) {
4001
4579
  const errorObj = error;
@@ -4008,44 +4586,6 @@ var ObjectStackAdapter = class {
4008
4586
  throw createErrorFromResponse(errorObj, `getObjectSchema(${objectName})`);
4009
4587
  }
4010
4588
  }
4011
- /**
4012
- * ADR-0056 P2 (epic #2398) — stamp structured-widget hints onto platform
4013
- * fields whose framework type is a storage primitive (e.g. `textarea`) but
4014
- * whose authoring UX should be a structured editor. Only the render `widget`
4015
- * is added; the field's `type` (the storage contract) is untouched. Applied
4016
- * idempotently to the cached schema so form + detail both honor it. Widget
4017
- * components are registered as `field:<widget>` in `@object-ui/fields`.
4018
- */
4019
- applyFieldWidgetOverrides(objectName, schema) {
4020
- const OVERRIDES = {
4021
- // ADR-0056 pure model — a permission set's six authorization facets are
4022
- // *designed* in Studio's structured editors and only *assigned* (to
4023
- // users) in Setup. In Setup they render read-only as a summary + a
4024
- // "Design in Studio →" deep-link (the `permission-facet-link` widget),
4025
- // never as raw [Object]/JSON. The capability *editor* itself lives in
4026
- // Studio (epic #2398 P2).
4027
- sys_permission_set: {
4028
- object_permissions: "permission-facet-link",
4029
- field_permissions: "permission-facet-link",
4030
- system_permissions: "permission-facet-link",
4031
- row_level_security: "permission-facet-link",
4032
- tab_permissions: "permission-facet-link",
4033
- admin_scope: "permission-facet-link"
4034
- }
4035
- };
4036
- const overrides = OVERRIDES[objectName];
4037
- const fields = schema && typeof schema === "object" ? schema.fields : null;
4038
- if (!overrides || !fields) return;
4039
- for (const [fname, widget] of Object.entries(overrides)) {
4040
- if (Array.isArray(fields)) {
4041
- const f = fields.find((x) => x?.name === fname);
4042
- if (f && !f.widget) f.widget = widget;
4043
- } else {
4044
- const f = fields[fname];
4045
- if (f && !f.widget) f.widget = widget;
4046
- }
4047
- }
4048
- }
4049
4589
  /**
4050
4590
  * Fetch a single object's schema while always revalidating the browser cache.
4051
4591
  *
@@ -4120,6 +4660,38 @@ var ObjectStackAdapter = class {
4120
4660
  return [];
4121
4661
  }
4122
4662
  }
4663
+ /**
4664
+ * List the items one metadata registry type declares —
4665
+ * `GET /api/v1/meta/:type`, through the client's `meta.getItems` — as
4666
+ * `{ name, label }` headers for the `position-ref` / `permission-ref` field
4667
+ * widgets (objectui#7611).
4668
+ *
4669
+ * Unlike {@link getObjects}, a failure REJECTS (the `DataSource` contract):
4670
+ * the transport's own error propagates, and an answer without the spec's
4671
+ * `items` list is refused rather than read as an empty registry, so a
4672
+ * failed load can never reach the widget as "this registry declares
4673
+ * nothing". Uncached, like `getObjects`: an item authored in Studio is
4674
+ * listed on the next form that opens.
4675
+ *
4676
+ * An item without a string `name` is not listed (the widget stores the
4677
+ * name, so there is nothing to store); a `label` that is not a string is
4678
+ * left off rather than coerced.
4679
+ */
4680
+ async listMetadataItems(type) {
4681
+ await this.connect();
4682
+ const result = await this.client.meta.getItems(type);
4683
+ if (!Array.isArray(result?.items)) {
4684
+ throw new Error(`GET /meta/${type} answered without an items list`);
4685
+ }
4686
+ const out = [];
4687
+ for (const it of result.items) {
4688
+ const name = it?.name;
4689
+ if (typeof name !== "string" || !name) continue;
4690
+ const label = it.label;
4691
+ out.push(typeof label === "string" && label ? { name, label } : { name });
4692
+ }
4693
+ return out;
4694
+ }
4123
4695
  /**
4124
4696
  * Get access to the underlying ObjectStack client for advanced operations.
4125
4697
  */
@@ -4186,14 +4758,16 @@ var ObjectStackAdapter = class {
4186
4758
  invalidateViewKeys(objectName, viewName) {
4187
4759
  this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
4188
4760
  this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
4761
+ this.metadataCache.invalidate?.(VIEW_LIST_KEY);
4189
4762
  }
4190
4763
  /**
4191
4764
  * Batch-fetch all persisted view overrides for an object.
4192
4765
  *
4193
4766
  * Per-view runtime overrides (density, column widths, sort, hidden
4194
- * columns, inlineEdit …) live in the SAME metadata namespace the
4195
- * write path uses: `type='view'`, `name=<viewId>` (see
4196
- * {@link updateViewConfig}). Loading them per-view fires N HTTP GETs
4767
+ * columns, inlineEdit …) live in the SAME metadata namespace the view
4768
+ * writes use: `type='view'`, `name=<viewId>` (see {@link createView} /
4769
+ * {@link updateView}; the toolbar's `updateViewConfig` wrote overlays there
4770
+ * until objectui#12504 retired it). Loading them per-view fires N HTTP GETs
4197
4771
  * that 404 for every view the user never customized — console noise on
4198
4772
  * every page load. This batch method performs a single
4199
4773
  * `GET /api/v1/meta/view` (returns `{type, items}`) and narrows the
@@ -4217,9 +4791,9 @@ var ObjectStackAdapter = class {
4217
4791
  *
4218
4792
  * Result is cached identically to {@link getView}, and EVERY view write —
4219
4793
  * on this adapter and above it — invalidates it through the one seam,
4220
- * {@link invalidateViewKeys}. For a long time only {@link updateViewConfig}
4221
- * did (objectui#4363), which left the other three adapter paths stale
4222
- * for the cache's 5-minute TTL. That gap does not self-heal: the consumer
4794
+ * {@link invalidateViewKeys}. For a long time only `updateViewConfig` (since
4795
+ * retired, objectui#12504) did (objectui#4363), which left the other three
4796
+ * adapter paths stale for the cache's 5-minute TTL. That gap does not self-heal: the consumer
4223
4797
  * (`loadViewOverrides`, app-shell `ObjectView`) treats a RESOLVED map as
4224
4798
  * authoritative and deliberately does not re-probe per view — objectui#3774,
4225
4799
  * and correct, since re-probing reinstates the 404 flurry the batch read
@@ -4235,7 +4809,7 @@ var ObjectStackAdapter = class {
4235
4809
  await this.connect();
4236
4810
  const cacheKey = `view-overrides:${objectName}`;
4237
4811
  return await this.metadataCache.get(cacheKey, async () => {
4238
- const result = await this.client.meta.getItems("view");
4812
+ const result = await this.readViewList();
4239
4813
  const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
4240
4814
  const out = {};
4241
4815
  for (const it of items) {
@@ -4270,104 +4844,6 @@ var ObjectStackAdapter = class {
4270
4844
  return null;
4271
4845
  }
4272
4846
  }
4273
- /**
4274
- * Persist a toolbar-driven view config patch — density, column widths,
4275
- * sort, hidden columns, inline edit. Symmetric counterpart to
4276
- * {@link getView}: writes the row to the server metadata store via
4277
- * `client.meta.saveItem`, then invalidates the matching cache entry so the
4278
- * next {@link getView} reflects the new payload. Returns the persisted item
4279
- * when the server echoes it, otherwise undefined.
4280
- *
4281
- * Called from exactly ONE production site — `ObjectView`'s
4282
- * `persistViewPatch`, for the toolbar toggle — but that ONE call site
4283
- * fires for BOTH kinds of active tab: a code-defined **system** view (no
4284
- * row of its own yet) and a genuinely user-created **saved** view (already
4285
- * has a row — the toggle is editing ITS OWN definition, not laying an
4286
- * overlay on top of it). Which one a given call means is NOT re-derived
4287
- * here from the write's shape (objectui#4227's own lesson: shape inference
4288
- * on this namespace is exactly what let a system view masquerade as
4289
- * saved) — the caller already knows, via the same `isSavedViewId`
4290
- * classification that gates the switcher's readonly flag and its five
4291
- * mutating handlers, and passes it as {@link opts.isSavedView}.
4292
- *
4293
- * - `isSavedView` false/omitted (system-view target, the common case and
4294
- * the default for backward compatibility): stamps
4295
- * {@link VIEW_OVERLAY_MARKER} so {@link listViews} excludes the row —
4296
- * the original objectui#4227 fix.
4297
- * - `isSavedView` true: the marker is withheld. Stamping it here would
4298
- * flag the saved view's OWN row as a personalization overlay, and
4299
- * `listViews()` would exclude it on the very next read — the user's own
4300
- * view would vanish from the switcher the moment they toggled its
4301
- * density (objectui#4227 follow-up, PM review on PR #4713, measured:
4302
- * `persistViewPatch` has no gate on which kind of tab is active, and
4303
- * this method writes to the exact same `(type='view', name=viewId)` key
4304
- * {@link createView}/the ADR-0034 `viewEnvelope` seam already used for
4305
- * that view, so the write is an upsert onto the saved view's row, not a
4306
- * new one).
4307
- *
4308
- * Per objectstack#7494's ruling, the overlay this writes (system-view
4309
- * case) is ORG-WIDE shared view settings, not a per-user preference — a
4310
- * true per-user scope is a parked v18 direction on the platform side.
4311
- *
4312
- * @param objectName - Object name (e.g. 'lead')
4313
- * @param viewId - View identifier (e.g. 'all_leads')
4314
- * @param config - Full view definition to persist
4315
- * @param opts.isSavedView - Whether `viewId` already names a saved view
4316
- * (vs. a system view being personalized for the first time). Omit /
4317
- * `false` for the default overlay-marking behavior.
4318
- */
4319
- /**
4320
- * [ADR-0066] Tell this adapter which system capabilities the session holds.
4321
- *
4322
- * Pass `undefined` for "no answer was reported" (a backend predating
4323
- * ADR-0066, or no permission provider mounted). Pass `[]` for a real,
4324
- * reported, empty grant — {@link maySetViewConfig} treats those two
4325
- * differently and callers must not collapse them, which is the same
4326
- * distinction `MePermissionsProvider` preserves natively (objectui#4656).
4327
- */
4328
- setSystemCapabilities(capabilities) {
4329
- this.systemCapabilities = capabilities ? [...capabilities] : void 0;
4330
- }
4331
- /**
4332
- * May this session write ORG-WIDE view configuration?
4333
- *
4334
- * **Unknown fails OPEN**, deliberately, and this is the one judgement in the
4335
- * gate worth reading twice. It is the doctrine `PermissionContextValue`
4336
- * .`hasCapabilities` states for every ADR-0066 gate in this repo
4337
- * (objectui#4656, framework#3923): the server enforces `manage_metadata` on
4338
- * the metadata door regardless of what the client believes, so a client-side
4339
- * denial on MISSING DATA cannot protect anything the server was not already
4340
- * protecting — it can only break a permitted user. Failing closed here would
4341
- * have refused the write on every deployment predating ADR-0066 and every
4342
- * host with no permission provider, which is "break the write for everyone"
4343
- * wearing the costume of a security fix.
4344
- *
4345
- * A REPORTED empty array gates strictly: "holds nothing" is a real answer.
4346
- */
4347
- maySetViewConfig() {
4348
- if (this.systemCapabilities === void 0) return true;
4349
- return this.systemCapabilities.includes(VIEW_CONFIG_CAPABILITY);
4350
- }
4351
- async updateViewConfig(objectName, viewId, config, opts) {
4352
- if (!this.maySetViewConfig()) {
4353
- throw new ViewConfigPermissionDeniedError(objectName, viewId);
4354
- }
4355
- await this.connect();
4356
- const merged = {
4357
- ...config || {},
4358
- object: config?.object || objectName,
4359
- name: viewId,
4360
- ...opts?.isSavedView ? {} : { [VIEW_OVERLAY_MARKER]: true }
4361
- };
4362
- const result = await this.client.meta.saveItem(
4363
- "view",
4364
- viewId,
4365
- merged
4366
- );
4367
- this.invalidateViewKeys(objectName, viewId);
4368
- if (result && result.item) return result.item;
4369
- return result ?? void 0;
4370
- }
4371
4847
  /**
4372
4848
  * List user-created views for a given object via the metadata overlay
4373
4849
  * API (ADR-0005). Replaces the legacy `find('sys_view', {...})` path
@@ -4424,7 +4900,7 @@ var ObjectStackAdapter = class {
4424
4900
  const draftItems = await this.metadataClient().withPreviewDrafts(true).list("view");
4425
4901
  items = Array.isArray(draftItems) ? draftItems : [];
4426
4902
  } else {
4427
- const result = await this.client.meta.getItems("view");
4903
+ const result = await this.readViewList();
4428
4904
  items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
4429
4905
  }
4430
4906
  const FORM_FAMILY = /* @__PURE__ */ new Set(["form", "detail"]);
@@ -4713,6 +5189,10 @@ var ObjectStackAdapter = class {
4713
5189
  * the PUBLISHED half after the draft was discarded carries the partial
4714
5190
  * state on the error's `outcome` — "draft gone, overlay left" is exactly
4715
5191
  * what the old `{ deleted: boolean }` could not express.
5192
+ * Its message is the adapter's "NOT fully removed" sentence followed, on
5193
+ * the next line, by the server's own reason (objectui#12416), so
5194
+ * `formatMetadataError(err)` shows the remedy a refusal names; the
5195
+ * original error stays on `cause`.
4716
5196
  *
4717
5197
  * Invalidates through {@link invalidateViewKeys}: the deleted row leaves the
4718
5198
  * batch override map too, and a ghost entry there is what the object page
@@ -4738,9 +5218,12 @@ var ObjectStackAdapter = class {
4738
5218
  draft,
4739
5219
  published: { removed: false }
4740
5220
  };
5221
+ const status = err?.status;
5222
+ const refused = typeof status === "number" && status >= 400 && status < 500;
4741
5223
  throw Object.assign(
4742
5224
  new Error(
4743
- `deleteView: view "${viewName}" on object "${objectName}" is NOT fully removed \u2014 the draft home was ${draft.removed ? "discarded" : "already absent"}, but deleting the published overlay failed. The published row is still serving the view; retry the delete.`
5225
+ `deleteView: view "${viewName}" on object "${objectName}" is NOT fully removed \u2014 the draft home was ${draft.removed ? "discarded" : "already absent"}, but deleting the published overlay failed. The published row is still serving the view${refused ? "." : "; retry the delete."}
5226
+ ` + formatMetadataError(err)
4744
5227
  ),
4745
5228
  { cause: err, outcome }
4746
5229
  );
@@ -4840,14 +5323,41 @@ var ObjectStackAdapter = class {
4840
5323
  * @param appName - the app name as it appears in the URL segment
4841
5324
  */
4842
5325
  async probeAppAccess(appName) {
4843
- if (!appName) return "unknown";
5326
+ return (await this.probeMetaItemAccess("app", appName)).verdict;
5327
+ }
5328
+ /**
5329
+ * {@link probeAppAccess} for any metadata type whose by-name read refuses a
5330
+ * caller (objectui#12112): why `name` is not in this session's list of
5331
+ * `type`, plus the server's own sentence when it refused.
5332
+ *
5333
+ * Since objectstack#22731 a dashboard and a list view carry the app's gate:
5334
+ * `GET /api/v1/meta/dashboard` and `/meta/view` list only what the caller may
5335
+ * open, so a withheld item and a missing one are the same absence there, and
5336
+ * the by-name read of one the caller does not hold answers `403`
5337
+ * `PERMISSION_DENIED`, "the app arm's whole refusal (same emitter, same
5338
+ * envelope)". The verdicts are therefore read exactly as for an app.
5339
+ *
5340
+ * `message` is the refusal's `message`, as the server wrote it: the one
5341
+ * sentence that names the item and says it was refused. Only on `denied`.
5342
+ *
5343
+ * Nothing here throws, and nothing is cached: a verdict about the CALLER
5344
+ * must not outlive a grant.
5345
+ *
5346
+ * @param type - the singular metadata type (`app`, `dashboard`, `view`)
5347
+ * @param name - the item's name, as the by-name route addresses it
5348
+ */
5349
+ async probeMetaItemAccess(type, name) {
5350
+ if (!type || !name) return { verdict: "unknown" };
4844
5351
  try {
4845
- const envelope = await this.client.meta.getItem("app", appName);
4846
- return metaEnvelopeCarriesItem(envelope) ? "granted" : "not_found";
5352
+ const envelope = await this.client.meta.getItem(type, name);
5353
+ return { verdict: metaEnvelopeCarriesItem(envelope) ? "granted" : "not_found" };
4847
5354
  } catch (err) {
4848
- if (isAppPermissionDeniedError(err)) return "denied";
4849
- if (isMetaItemAbsentError(err)) return "not_found";
4850
- return "unreachable";
5355
+ if (isAppPermissionDeniedError(err)) {
5356
+ const message = err?.message;
5357
+ return typeof message === "string" && message.trim() ? { verdict: "denied", message } : { verdict: "denied" };
5358
+ }
5359
+ if (isMetaItemAbsentError(err)) return { verdict: "not_found" };
5360
+ return { verdict: "unreachable" };
4851
5361
  }
4852
5362
  }
4853
5363
  /**
@@ -4935,12 +5445,9 @@ var ObjectStackAdapter = class {
4935
5445
  throw new AnalyticsResultShapeError(resource, contractResult);
4936
5446
  }
4937
5447
  const rawRows = contractResult.rows;
4938
- const measureMissing = rawRows.length > 0 && rawRows.every((row) => {
4939
- if (row == null) return true;
4940
- if (measureName in row && row[measureName] != null) return false;
4941
- if (valueKey in row && row[valueKey] != null) return false;
4942
- return true;
4943
- });
5448
+ const measureMissing = rawRows.length > 0 && rawRows.every(
5449
+ (row) => row == null || !(measureName in row) && !(valueKey in row)
5450
+ );
4944
5451
  if (measureMissing) {
4945
5452
  return await this.aggregateViaFind(resource, params);
4946
5453
  }
@@ -5391,13 +5898,12 @@ export {
5391
5898
  OBJECT_METADATA_TYPE,
5392
5899
  ObjectStackAdapter,
5393
5900
  ObjectStackError,
5901
+ PREFERENCE_FAMILY_ROW_LIMIT,
5394
5902
  RELATIONSHIP_TYPES_REQUIRING_REFERENCE,
5395
5903
  UNRECOGNIZED_DROP_REASON,
5396
5904
  UnlowerableAnalyticsFilterError,
5397
5905
  UnloweredAggregateWhereError,
5398
- VIEW_CONFIG_CAPABILITY,
5399
5906
  VIEW_OVERLAY_OWNED_KEYS,
5400
- ViewConfigPermissionDeniedError,
5401
5907
  assertObjectMetadataWritable,
5402
5908
  classifyAnalyticsFailure,
5403
5909
  classifyImportMappingsFailure,
@@ -5405,13 +5911,17 @@ export {
5405
5911
  clearSharedDiscoveryCache,
5406
5912
  createErrorFromResponse,
5407
5913
  createObjectStackAdapter,
5914
+ createObjectStackUserPreferenceFamily,
5408
5915
  createObjectStackUserStateAdapter,
5409
5916
  createQuietHttpLogger,
5917
+ draftChangesUnder,
5918
+ dropServedPicklistOptions,
5410
5919
  extractDraftBody,
5411
5920
  formatHttpFailureMessage,
5412
5921
  formatMetadataError,
5413
5922
  formatMetadataIssue,
5414
5923
  getSharedDiscovery,
5924
+ governingDraftChange,
5415
5925
  is404Error,
5416
5926
  isAnalyticsNotInstalledError,
5417
5927
  isApiAccessDeniedError,
@@ -5421,9 +5931,9 @@ export {
5421
5931
  isMalformedFilterError,
5422
5932
  isMetaItemAbsentError,
5423
5933
  isObjectStackError,
5424
- isViewConfigPermissionDeniedError,
5425
5934
  narrowPersonalizationOverlay,
5426
5935
  normaliseClientError,
5936
+ readDraftChanges,
5427
5937
  readSaveAdvisories,
5428
5938
  readTransactionalBatchCapability,
5429
5939
  serializeOrderBy,