@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.cjs CHANGED
@@ -45,13 +45,12 @@ __export(index_exports, {
45
45
  OBJECT_METADATA_TYPE: () => OBJECT_METADATA_TYPE,
46
46
  ObjectStackAdapter: () => ObjectStackAdapter,
47
47
  ObjectStackError: () => ObjectStackError,
48
+ PREFERENCE_FAMILY_ROW_LIMIT: () => PREFERENCE_FAMILY_ROW_LIMIT,
48
49
  RELATIONSHIP_TYPES_REQUIRING_REFERENCE: () => RELATIONSHIP_TYPES_REQUIRING_REFERENCE,
49
50
  UNRECOGNIZED_DROP_REASON: () => UNRECOGNIZED_DROP_REASON,
50
51
  UnlowerableAnalyticsFilterError: () => UnlowerableAnalyticsFilterError,
51
52
  UnloweredAggregateWhereError: () => UnloweredAggregateWhereError,
52
- VIEW_CONFIG_CAPABILITY: () => VIEW_CONFIG_CAPABILITY,
53
53
  VIEW_OVERLAY_OWNED_KEYS: () => VIEW_OVERLAY_OWNED_KEYS,
54
- ViewConfigPermissionDeniedError: () => ViewConfigPermissionDeniedError,
55
54
  assertObjectMetadataWritable: () => assertObjectMetadataWritable,
56
55
  classifyAnalyticsFailure: () => classifyAnalyticsFailure,
57
56
  classifyImportMappingsFailure: () => classifyImportMappingsFailure,
@@ -59,13 +58,17 @@ __export(index_exports, {
59
58
  clearSharedDiscoveryCache: () => clearSharedDiscoveryCache,
60
59
  createErrorFromResponse: () => createErrorFromResponse,
61
60
  createObjectStackAdapter: () => createObjectStackAdapter,
61
+ createObjectStackUserPreferenceFamily: () => createObjectStackUserPreferenceFamily,
62
62
  createObjectStackUserStateAdapter: () => createObjectStackUserStateAdapter,
63
63
  createQuietHttpLogger: () => createQuietHttpLogger,
64
+ draftChangesUnder: () => draftChangesUnder,
65
+ dropServedPicklistOptions: () => dropServedPicklistOptions,
64
66
  extractDraftBody: () => extractDraftBody,
65
67
  formatHttpFailureMessage: () => formatHttpFailureMessage,
66
68
  formatMetadataError: () => formatMetadataError,
67
69
  formatMetadataIssue: () => formatMetadataIssue,
68
70
  getSharedDiscovery: () => getSharedDiscovery,
71
+ governingDraftChange: () => governingDraftChange,
69
72
  is404Error: () => is404Error,
70
73
  isAnalyticsNotInstalledError: () => isAnalyticsNotInstalledError,
71
74
  isApiAccessDeniedError: () => isApiAccessDeniedError,
@@ -75,9 +78,9 @@ __export(index_exports, {
75
78
  isMalformedFilterError: () => isMalformedFilterError,
76
79
  isMetaItemAbsentError: () => isMetaItemAbsentError,
77
80
  isObjectStackError: () => isObjectStackError,
78
- isViewConfigPermissionDeniedError: () => isViewConfigPermissionDeniedError,
79
81
  narrowPersonalizationOverlay: () => narrowPersonalizationOverlay,
80
82
  normaliseClientError: () => normaliseClientError,
83
+ readDraftChanges: () => readDraftChanges,
81
84
  readSaveAdvisories: () => readSaveAdvisories,
82
85
  readTransactionalBatchCapability: () => readTransactionalBatchCapability,
83
86
  serializeOrderBy: () => serializeOrderBy,
@@ -89,6 +92,7 @@ var import_ui2 = require("@objectstack/spec/ui");
89
92
  var import_data4 = require("@objectstack/spec/data");
90
93
  var import_data5 = require("@objectstack/spec/data");
91
94
  var import_data6 = require("@objectstack/spec/data");
95
+ var import_api3 = require("@objectstack/spec/api");
92
96
  var import_kernel2 = require("@objectstack/spec/kernel");
93
97
 
94
98
  // src/metadata-client.ts
@@ -133,20 +137,20 @@ function fieldEntries(fields) {
133
137
  }
134
138
  return [];
135
139
  }
136
- function assertObjectMetadataWritable(type, item, writer) {
140
+ function assertObjectMetadataWritable(type, item, _writer) {
137
141
  if (String(type) !== OBJECT_METADATA_TYPE) return;
138
142
  if (!item || typeof item !== "object") return;
139
143
  const fields = item.fields;
140
144
  for (const { name, def } of fieldEntries(fields)) {
141
145
  if (CHOICE_TYPES_REQUIRING_OPTIONS.includes(String(def.type)) && !hasOptionSource(def)) {
142
146
  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.`
147
+ `The object was not saved: the field \`${name}\` is a \`${String(def.type)}\` with no options: it carries ${describeOptions(def.options)}, so it offers nothing to choose and nothing to check a stored value against. A choice field needs at least one option or a shared \`picklist\`, so this draft stays here until it has one. Add an option, or change the field to a non-choice type.`
144
148
  );
145
149
  }
146
150
  if (!RELATIONSHIP_TYPES_REQUIRING_REFERENCE.includes(String(def.type))) continue;
147
151
  if (isUsableTarget(def.reference)) continue;
148
152
  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.`
153
+ `The object was not saved: the field \`${name}\` is a \`${String(def.type)}\` and carries ${describeTarget(def.reference)}, so it names no object to link to. The server refuses the same document with a 422 on \`fields.${name}.reference\`, and that refusal blocks every later save of this object for as long as the half-filled field rides along in the draft. Pick the target object, or change the field to a non-relationship type.`
150
154
  );
151
155
  }
152
156
  }
@@ -162,6 +166,15 @@ function readSaveAdvisories(body) {
162
166
  return typeof c.rule === "string" && typeof c.path === "string" && typeof c.where === "string" && typeof c.message === "string" && typeof c.hint === "string" && typeof c.severity === "string";
163
167
  });
164
168
  }
169
+ function readRegistrations(raw) {
170
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
171
+ const out = {};
172
+ for (const [name, verdict] of Object.entries(raw)) {
173
+ const parsed = import_api.FlowRegistrationSchema.safeParse(verdict);
174
+ if (parsed.success) out[name] = parsed.data;
175
+ }
176
+ return out;
177
+ }
165
178
  var PROTECTION_ENVELOPE_KEYS = [
166
179
  "overlayScope",
167
180
  "lock",
@@ -228,6 +241,26 @@ function isMetaItemEnvelope(value) {
228
241
  const env = value;
229
242
  return typeof env.type === "string" && typeof env.name === "string" && "item" in env;
230
243
  }
244
+ var pendingReads = /* @__PURE__ */ new WeakMap();
245
+ function pendingReadsOn(transport) {
246
+ let reads = pendingReads.get(transport);
247
+ if (!reads) {
248
+ reads = /* @__PURE__ */ new Map();
249
+ pendingReads.set(transport, reads);
250
+ }
251
+ return reads;
252
+ }
253
+ function handOut(entry) {
254
+ entry.waiting += 1;
255
+ return entry.read.then((value) => {
256
+ entry.waiting -= 1;
257
+ return entry.waiting === 0 ? value : ownCopy(value);
258
+ });
259
+ }
260
+ function ownCopy(value) {
261
+ if (value === null || typeof value !== "object") return value;
262
+ return typeof structuredClone === "function" ? structuredClone(value) : JSON.parse(JSON.stringify(value));
263
+ }
231
264
  var MetadataClient = class _MetadataClient {
232
265
  constructor(config) {
233
266
  __publicField(this, "base");
@@ -278,34 +311,102 @@ var MetadataClient = class _MetadataClient {
278
311
  ...this.onSaveAdvisory ? { onSaveAdvisory: this.onSaveAdvisory } : {}
279
312
  });
280
313
  }
314
+ /**
315
+ * Share one in-flight GET among the callers asking the same method for the
316
+ * same URL with the same headers through the same transport (objectui#11797).
317
+ *
318
+ * Opening a Studio package mounts several readers that ask the same
319
+ * question at once, and each one sent its own request. The first call for a
320
+ * key sends `read()`; a call that arrives while it is pending waits for that
321
+ * request instead of sending another.
322
+ *
323
+ * ⛔ Not a response cache, deliberately — the rules `findOne` keeps on the
324
+ * data adapter (objectui#11699):
325
+ * - the entry goes the moment the read settles, so a call made after an
326
+ * answer always reaches the network (there is no reuse window: a client
327
+ * cannot see every write that changes metadata, an agent's server-side
328
+ * edits among them, and a window would answer the read after such a write
329
+ * with the state from before it);
330
+ * - a rejection reaches every caller already waiting and is not remembered;
331
+ * - a write sent through the same transport drops every pending read when
332
+ * it lands (see {@link sendWrite}), so a read asked after a save or a
333
+ * publish is never answered by a request sent before it;
334
+ * - every caller, the first one included, gets an answer no other caller
335
+ * holds (see {@link handOut}).
336
+ */
337
+ shareRead(reader, url, read) {
338
+ const reads = pendingReadsOn(this.fetchImpl);
339
+ const key = JSON.stringify([reader, url, Object.entries(this.headers).sort(([a], [b]) => a.localeCompare(b))]);
340
+ let entry = reads.get(key);
341
+ if (!entry) {
342
+ const created = { read: read(), waiting: 0 };
343
+ reads.set(key, created);
344
+ const settle = () => {
345
+ if (reads.get(key) === created) reads.delete(key);
346
+ };
347
+ created.read.then(settle, settle);
348
+ entry = created;
349
+ }
350
+ return handOut(entry);
351
+ }
352
+ /**
353
+ * Send a write, then drop every read still pending on this transport
354
+ * (objectui#11797): a read on the wire when the write lands was asked before
355
+ * it, so a caller asking after the write must not join it. Dropped however
356
+ * the write ends — a refused or interrupted write may still have changed
357
+ * something. The callers already holding a pending read keep its answer.
358
+ */
359
+ async sendWrite(url, init) {
360
+ try {
361
+ return await this.fetchImpl(url, init);
362
+ } finally {
363
+ pendingReads.get(this.fetchImpl)?.clear();
364
+ }
365
+ }
281
366
  /** List all registered metadata types (returns the registry rows). */
282
367
  async listTypes() {
283
- const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
284
- if (!res.ok) throw await parseError(res);
285
- const data = await res.json();
286
- if (Array.isArray(data)) return data;
287
- if (data && Array.isArray(data.entries)) return data.entries;
288
- if (data && Array.isArray(data.items)) return data.items;
289
- if (data && Array.isArray(data.types)) {
290
- return data.types.map((t) => ({ type: t }));
291
- }
292
- return [];
368
+ return this.shareRead("listTypes", this.base, async () => {
369
+ const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
370
+ if (!res.ok) throw await parseError(res);
371
+ const data = await res.json();
372
+ if (Array.isArray(data)) return data;
373
+ if (data && Array.isArray(data.entries)) return data.entries;
374
+ if (data && Array.isArray(data.items)) return data.items;
375
+ if (data && Array.isArray(data.types)) {
376
+ return data.types.map((t) => ({ type: t }));
377
+ }
378
+ return [];
379
+ });
293
380
  }
294
381
  /** List items of a metadata type (e.g. `object`, `field`, `view`). */
295
382
  async list(type, options = {}) {
383
+ return (await this.listResponse(type, options)).items;
384
+ }
385
+ /**
386
+ * The same list read as {@link list}, with what the server serves beside
387
+ * the items (objectui#12349): today that is the automation engine's
388
+ * `registrations` on a `flow` list — see {@link MetadataListAnswer}.
389
+ *
390
+ * One request for both methods: `list` is this read with the items picked
391
+ * out, so a caller asking both at once shares one in-flight GET.
392
+ */
393
+ async listResponse(type, options = {}) {
296
394
  const params = [];
297
395
  if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
298
396
  if (this.previewDrafts) params.push("preview=draft");
299
397
  const qs = params.length ? `?${params.join("&")}` : "";
300
398
  const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
301
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
302
- if (!res.ok) throw await parseError(res);
303
- const data = await res.json();
304
- if (Array.isArray(data)) return data;
305
- if (data && Array.isArray(data.items)) {
306
- return data.items;
307
- }
308
- return [];
399
+ return this.shareRead("list", url, async () => {
400
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
401
+ if (!res.ok) throw await parseError(res);
402
+ const data = await res.json();
403
+ if (Array.isArray(data)) return { items: data };
404
+ if (data && Array.isArray(data.items)) {
405
+ const registrations = readRegistrations(data.registrations);
406
+ return { items: data.items, ...registrations ? { registrations } : {} };
407
+ }
408
+ return { items: [] };
409
+ });
309
410
  }
310
411
  /**
311
412
  * List pending DRAFT items (ADR-0033) — what an AI authored but nobody
@@ -321,11 +422,13 @@ var MetadataClient = class _MetadataClient {
321
422
  if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
322
423
  const qs = params.length ? `?${params.join("&")}` : "";
323
424
  const url = `${this.base}/_drafts${qs}`;
324
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
325
- if (!res.ok) throw await parseError(res);
326
- const data = await res.json();
327
- if (Array.isArray(data)) return data;
328
- return data?.drafts ?? data?.data?.drafts ?? [];
425
+ return this.shareRead("listDrafts", url, async () => {
426
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
427
+ if (!res.ok) throw await parseError(res);
428
+ const data = await res.json();
429
+ if (Array.isArray(data)) return data;
430
+ return data?.drafts ?? data?.data?.drafts ?? [];
431
+ });
329
432
  }
330
433
  /**
331
434
  * Fetch a single metadata item and return the response body EXACTLY as the
@@ -344,10 +447,12 @@ var MetadataClient = class _MetadataClient {
344
447
  if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
345
448
  const qs = params.length ? `?${params.join("&")}` : "";
346
449
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
347
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
348
- if (res.status === 404) return null;
349
- if (!res.ok) throw await parseError(res);
350
- return await res.json();
450
+ return this.shareRead("readItemResponse", url, async () => {
451
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
452
+ if (res.status === 404) return null;
453
+ if (!res.ok) throw await parseError(res);
454
+ return await res.json();
455
+ });
351
456
  }
352
457
  /**
353
458
  * Get a single metadata item. Returns the unwrapped item CONTENT — the
@@ -403,13 +508,51 @@ var MetadataClient = class _MetadataClient {
403
508
  * (app-shell) and `unwrapViewDraft` (this package) read a draft that arrived
404
509
  * through {@link get}, which already unwrapped the envelope. They split on
405
510
  * which method feeds them, not on what they mean — all three strip.
511
+ *
512
+ * objectui#11799 — whether a draft exists is answered by the pending-drafts
513
+ * ledger first (see {@link draftLedgerMayHold}), and `?state=draft` is sent
514
+ * only when the ledger lists the name. The item read answers 404 when there
515
+ * is no draft, so opening every item in Studio used to log one error per
516
+ * item for an answer the ledger gives with a 200.
406
517
  */
407
518
  async getDraft(type, name, options = {}) {
519
+ if (!await this.draftLedgerMayHold(name)) return null;
408
520
  return this.readItemResponse(type, name, {
409
521
  state: "draft",
410
522
  ...options.packageId ? { packageId: options.packageId } : {}
411
523
  });
412
524
  }
525
+ /**
526
+ * Whether the pending-drafts ledger (`GET /meta/_drafts`) may hold a draft
527
+ * named `name` (objectui#11799).
528
+ *
529
+ * Read through {@link listDrafts}, the shared read objectui#11797 made: a
530
+ * question asked while a ledger read is pending waits for it, and a write
531
+ * through this transport drops it when the write lands ({@link sendWrite}),
532
+ * so a draft saved a moment ago is never reported absent by a read sent
533
+ * before the save.
534
+ *
535
+ * The WHOLE ledger, matched by name. The `?state=draft` read finds a row
536
+ * under either spelling of the type, and, when it names a package, that
537
+ * package's row or the package-less one. The ledger's `type` and `packageId`
538
+ * filters are exact, so a filtered ledger could miss a draft the item read
539
+ * would serve, and the editor would open the published body over a pending
540
+ * draft. Unfiltered, every miss here is a real absence; a same-name row of
541
+ * another type or package only costs the item read that was always sent.
542
+ *
543
+ * A ledger that cannot be read (403 without an authoring capability, 501 on
544
+ * a kernel without drafts, a network fault) is UNKNOWN, not empty: the item
545
+ * read is sent, as before.
546
+ */
547
+ async draftLedgerMayHold(name) {
548
+ let drafts;
549
+ try {
550
+ drafts = await this.listDrafts();
551
+ } catch {
552
+ return true;
553
+ }
554
+ return drafts.some((d) => d?.name === name);
555
+ }
413
556
  /**
414
557
  * Report the runtime authoring gate's advisory findings for a write the
415
558
  * server has ALREADY committed (#4133 for the save door, #5026 for the
@@ -447,11 +590,17 @@ var MetadataClient = class _MetadataClient {
447
590
  `MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
448
591
  );
449
592
  }
593
+ if (options.ifMatch && options.ifNoneMatch) {
594
+ throw new Error(
595
+ `MetadataClient.save: ifMatch and ifNoneMatch are mutually exclusive (type="${type}", name="${name}"). Send ifMatch with the version you read, or ifNoneMatch: "*" when no row is there.`
596
+ );
597
+ }
450
598
  assertObjectMetadataWritable(type, item, "MetadataClient.save");
451
599
  const params = [];
452
600
  if (options.force) params.push("force=true");
453
601
  if (options.mode === "draft") params.push("mode=draft");
454
602
  if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
603
+ if (options.surface) params.push(`surface=${encodeURIComponent(options.surface)}`);
455
604
  const qs = params.length ? `?${params.join("&")}` : "";
456
605
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
457
606
  const headers = {
@@ -459,7 +608,8 @@ var MetadataClient = class _MetadataClient {
459
608
  "Content-Type": "application/json"
460
609
  };
461
610
  if (options.ifMatch) headers["If-Match"] = options.ifMatch;
462
- const res = await this.fetchImpl(url, {
611
+ if (options.ifNoneMatch) headers["If-None-Match"] = options.ifNoneMatch;
612
+ const res = await this.sendWrite(url, {
463
613
  method: "PUT",
464
614
  headers,
465
615
  body: JSON.stringify(item)
@@ -540,17 +690,40 @@ var MetadataClient = class _MetadataClient {
540
690
  * {@link listDrafts} reads `data?.data?.drafts` because `GET /meta/_drafts`
541
691
  * IS a dispatcher route, and `publishHealthFromResponse` unwraps because the
542
692
  * batch door really is enveloped. The rule is per-route, not per-file.
693
+ *
694
+ * ## The bundle member (objectui#12402)
695
+ *
696
+ * Pass `member` to publish one member of a type whose identity is a pair
697
+ * (today only `email_template`, `(name, locale)`), sent as `?member=` — the
698
+ * selector the by-name doors read since objectstack `038d4c958`, and the
699
+ * value a `GET /meta/_drafts` row carries as `member`. A publish that names
700
+ * none promotes the CANONICAL member's draft, so two languages of one
701
+ * template drafted outside a package cannot both be published by name
702
+ * without it. The parameter is OMITTED, never sent empty, when the caller
703
+ * has no member: the door refuses a blank selector, and refuses any
704
+ * selector on a type with no members.
543
705
  */
544
- async publishDraft(type, name) {
545
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
546
- const res = await this.fetchImpl(url, {
706
+ async publishDraft(type, name, options = {}) {
707
+ const params = [];
708
+ if (options.member) params.push(`member=${encodeURIComponent(options.member)}`);
709
+ const qs = params.length ? `?${params.join("&")}` : "";
710
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish${qs}`;
711
+ const res = await this.sendWrite(url, {
547
712
  method: "POST",
548
713
  headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
549
714
  body: "{}"
550
715
  });
551
716
  if (!res.ok) throw await parseError(res);
552
717
  const body = await res.json().catch(() => ({}));
553
- this.emitAdvisories(body, { type, name, door: "publish", mode: "publish" });
718
+ this.emitAdvisories(body, {
719
+ type,
720
+ name,
721
+ // objectui#12413 — the member this publish named, under the same rule as
722
+ // the query parameter above: present only when the caller had one.
723
+ ...options.member ? { member: options.member } : {},
724
+ door: "publish",
725
+ mode: "publish"
726
+ });
554
727
  return body;
555
728
  }
556
729
  /**
@@ -598,22 +771,32 @@ var MetadataClient = class _MetadataClient {
598
771
  * says to read `outcome`, not the boolean), and the two callers have their
599
772
  * own, different rules for the refusal and rolled-back cases. Deciding that
600
773
  * here would change behaviour this card is not about.
774
+ *
775
+ * ## The opt-in backfill (objectui#12929)
776
+ *
777
+ * `options.backfill` is sent as the body's `backfill` key, as given, and only
778
+ * when the caller passes one ({@link MetadataPublishPackageDraftsOptions}).
779
+ * Without it the body is `{}`, byte for byte what every caller sent before,
780
+ * so a caller that names nothing writes nothing into existing rows. The
781
+ * receipt is `backfillApplied` on the returned body, unexamined like the rest
782
+ * of a 2xx: one element per named field with the rows written, and `error`
783
+ * on a field that stopped short (the publish itself stands).
601
784
  */
602
- async publishPackageDrafts(packageId) {
785
+ async publishPackageDrafts(packageId, options = {}) {
603
786
  if (!packageId || !String(packageId).trim()) {
604
787
  throw new Error(
605
788
  "MetadataClient.publishPackageDrafts: packageId must be non-empty. The POST /packages/:id/publish-drafts route requires an id segment."
606
789
  );
607
790
  }
608
791
  const url = `${packagesBaseOf(this.base)}/${encodeURIComponent(packageId)}/publish-drafts`;
609
- const res = await this.fetchImpl(url, {
792
+ const res = await this.sendWrite(url, {
610
793
  method: "POST",
611
794
  // Both call sites this replaces sent the session cookie, and the client's
612
795
  // own fetch adds the console's Bearer token — so the routed call carries a
613
796
  // superset of what the bare doors carried, never less.
614
797
  credentials: "include",
615
798
  headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
616
- body: "{}"
799
+ body: JSON.stringify(options.backfill !== void 0 ? { backfill: options.backfill } : {})
617
800
  });
618
801
  if (!res.ok) throw await parseError(res);
619
802
  const body = unwrapDispatcherEnvelope(await res.json().catch(() => ({})));
@@ -763,13 +946,22 @@ var MetadataClient = class _MetadataClient {
763
946
  * Idempotent: returns the result even when no overlay row existed.
764
947
  * Pass `state: 'draft'` to discard the pending draft only (keeps the
765
948
  * published overlay intact) — useful for a Studio "Discard draft" button.
949
+ *
950
+ * Pass `packageId` to name the package whose row the delete removes, sent
951
+ * as `?package=`. A delete that names none addresses the one package that
952
+ * holds the item; when two or more packages hold it the door refuses it
953
+ * `409 RESOURCE_CONFLICT`, deletes nothing, and names `?package=` as the
954
+ * remedy. That refusal is thrown like any other {@link MetadataError}.
766
955
  */
767
956
  async reset(type, name, options = {}) {
768
- const qs = options.state === "draft" ? "?state=draft" : "";
957
+ const params = [];
958
+ if (options.state === "draft") params.push("state=draft");
959
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
960
+ const qs = params.length ? `?${params.join("&")}` : "";
769
961
  const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
770
962
  const headers = { ...this.headers };
771
963
  if (options.ifMatch) headers["If-Match"] = options.ifMatch;
772
- const res = await this.fetchImpl(url, { method: "DELETE", headers });
964
+ const res = await this.sendWrite(url, { method: "DELETE", headers });
773
965
  if (!res.ok) throw await parseError(res);
774
966
  return await res.json();
775
967
  }
@@ -793,7 +985,7 @@ var MetadataClient = class _MetadataClient {
793
985
  ...this.headers,
794
986
  "Content-Type": "application/json"
795
987
  };
796
- const res = await this.fetchImpl(url, {
988
+ const res = await this.sendWrite(url, {
797
989
  method: "POST",
798
990
  headers,
799
991
  body: JSON.stringify(options.message ? { message: options.message } : {})
@@ -817,7 +1009,7 @@ var MetadataClient = class _MetadataClient {
817
1009
  ...this.headers,
818
1010
  "Content-Type": "application/json"
819
1011
  };
820
- const res = await this.fetchImpl(url, {
1012
+ const res = await this.sendWrite(url, {
821
1013
  method: "POST",
822
1014
  headers,
823
1015
  body: JSON.stringify({
@@ -1430,21 +1622,54 @@ var MetadataCache = class {
1430
1622
  this.cache.delete(key);
1431
1623
  }
1432
1624
  }
1625
+ return this.load(key, fetcher);
1626
+ }
1627
+ /**
1628
+ * Join the read in flight for `key`, or start one, and never answer from the
1629
+ * settled entry: a caller of this always gets an answer from a request that
1630
+ * was on the wire when it asked.
1631
+ *
1632
+ * objectui#13044. This is the door for a caller whose own contract is "ask
1633
+ * the server", which should still not put a second identical request on the
1634
+ * wire while one is already there.
1635
+ *
1636
+ * objectui#13057. The landing is stored, exactly as one {@link get} started
1637
+ * would store it, so a later {@link get} for `key` may be answered from it.
1638
+ * The caller asked the server; a reader that may be served what the cache
1639
+ * already holds is served the answer that caller just received.
1640
+ */
1641
+ share(key, fetcher) {
1642
+ return this.load(key, fetcher);
1643
+ }
1644
+ /**
1645
+ * The in-flight half of {@link get} and {@link share}: join the request in
1646
+ * flight for `key`, or start one.
1647
+ *
1648
+ * A landing is stored, and its in-flight entry cleared, only
1649
+ * while it is still the request registered for `key` (objectui#13044). {@link invalidate}
1650
+ * and {@link clear} detach the request in flight, so a request sent before an
1651
+ * invalidation can neither answer a caller who asks after it nor write its
1652
+ * answer over a newer one.
1653
+ */
1654
+ load(key, fetcher) {
1433
1655
  const existing = this.inflight.get(key);
1434
1656
  if (existing) {
1435
1657
  this.stats.coalesced++;
1436
1658
  return existing;
1437
1659
  }
1438
1660
  this.stats.misses++;
1439
- const promise = (async () => {
1661
+ const self = {};
1662
+ const run = async () => {
1440
1663
  try {
1441
1664
  const data = await fetcher();
1442
- this.set(key, data);
1665
+ if (this.inflight.get(key) === self.promise) this.set(key, data);
1443
1666
  return data;
1444
1667
  } finally {
1445
- this.inflight.delete(key);
1668
+ if (this.inflight.get(key) === self.promise) this.inflight.delete(key);
1446
1669
  }
1447
- })();
1670
+ };
1671
+ const promise = run();
1672
+ self.promise = promise;
1448
1673
  this.inflight.set(key, promise);
1449
1674
  return promise;
1450
1675
  }
@@ -1492,8 +1717,10 @@ var MetadataCache = class {
1492
1717
  invalidate(key) {
1493
1718
  if (key) {
1494
1719
  this.cache.delete(key);
1720
+ this.inflight.delete(key);
1495
1721
  } else {
1496
1722
  this.cache.clear();
1723
+ this.inflight.clear();
1497
1724
  }
1498
1725
  }
1499
1726
  /**
@@ -1563,6 +1790,21 @@ var MetadataCache = class {
1563
1790
  }
1564
1791
  };
1565
1792
 
1793
+ // src/metadata-error.ts
1794
+ function extractIssues(e) {
1795
+ const issues = e?.issues;
1796
+ return Array.isArray(issues) ? issues : [];
1797
+ }
1798
+ function formatMetadataIssue(i) {
1799
+ return `\u2022 ${i.path && i.path.length > 0 ? i.path : "(root)"} \u2014 ${i.message}`;
1800
+ }
1801
+ function formatMetadataError(e) {
1802
+ const issues = extractIssues(e);
1803
+ if (issues.length > 0) return issues.map(formatMetadataIssue).join("\n");
1804
+ const err = e;
1805
+ return err?.message ?? String(e);
1806
+ }
1807
+
1566
1808
  // src/errors.ts
1567
1809
  var ObjectStackError = class extends Error {
1568
1810
  /**
@@ -1771,51 +2013,67 @@ function present(value) {
1771
2013
 
1772
2014
  // src/userState.ts
1773
2015
  var DEFAULT_RESOURCE = "sys_user_preference";
1774
- function createObjectStackUserStateAdapter(options) {
1775
- const {
1776
- dataSource,
1777
- userId,
1778
- key,
1779
- resource = DEFAULT_RESOURCE,
1780
- onError = () => {
1781
- }
1782
- } = options;
2016
+ var PREFERENCE_FAMILY_ROW_LIMIT = 200;
2017
+ function preferenceQuery(userId, keys, prefixes = []) {
2018
+ if (prefixes.length === 0) {
2019
+ if (keys.length === 1) {
2020
+ return { $filter: { user_id: userId, key: keys[0] }, $top: 1 };
2021
+ }
2022
+ return { $filter: { user_id: userId, key: { $in: [...keys] } }, $top: keys.length };
2023
+ }
2024
+ const arms = [];
2025
+ if (keys.length === 1) arms.push({ key: keys[0] });
2026
+ else if (keys.length > 1) arms.push({ key: { $in: [...keys] } });
2027
+ for (const prefix of prefixes) arms.push({ key: { $startsWith: prefix } });
2028
+ return {
2029
+ $filter: { user_id: userId, ...arms.length === 1 ? arms[0] : { $or: arms } },
2030
+ $top: keys.length + prefixes.length * PREFERENCE_FAMILY_ROW_LIMIT
2031
+ };
2032
+ }
2033
+ var openReads = /* @__PURE__ */ new WeakMap();
2034
+ function joinSharedRead(dataSource, resource, userId, want) {
2035
+ let scopes = openReads.get(dataSource);
2036
+ if (!scopes) {
2037
+ scopes = /* @__PURE__ */ new Map();
2038
+ openReads.set(dataSource, scopes);
2039
+ }
2040
+ const scope = JSON.stringify([resource, userId]);
2041
+ const open = scopes.get(scope);
2042
+ if (open) {
2043
+ if ("key" in want) open.keys.add(want.key);
2044
+ else open.prefixes.add(want.prefix);
2045
+ return open;
2046
+ }
2047
+ const keys = new Set("key" in want ? [want.key] : []);
2048
+ const prefixes = new Set("prefix" in want ? [want.prefix] : []);
2049
+ const opened = scopes;
2050
+ const read = {
2051
+ keys,
2052
+ prefixes,
2053
+ answer: Promise.resolve().then(async () => {
2054
+ if (opened.get(scope) === read) opened.delete(scope);
2055
+ const query = preferenceQuery(userId, [...keys], [...prefixes]);
2056
+ const result = await dataSource.find(resource, query);
2057
+ const rows = result?.data ?? [];
2058
+ return {
2059
+ rows,
2060
+ saturated: prefixes.size > 0 && rows.length >= (query.$top ?? Infinity)
2061
+ };
2062
+ })
2063
+ };
2064
+ scopes.set(scope, read);
2065
+ return read;
2066
+ }
2067
+ function createPreferenceRowWriter(dataSource, resource, userId, key, onError) {
1783
2068
  let cachedRowId = null;
1784
- let saveChain = Promise.resolve();
1785
2069
  const findExisting = async () => {
1786
- const params = {
1787
- $filter: {
1788
- user_id: userId,
1789
- key
1790
- },
1791
- $top: 1
1792
- };
1793
- const result = await dataSource.find(resource, params);
1794
- const rows = result?.data ?? [];
1795
- const match = rows.find((r) => {
1796
- if (!r) return false;
1797
- const userMismatch = r.user_id !== void 0 && r.user_id !== userId;
1798
- const keyMismatch = r.key !== void 0 && r.key !== key;
1799
- return !userMismatch && !keyMismatch;
1800
- });
1801
- return match ?? null;
2070
+ const result = await dataSource.find(resource, preferenceQuery(userId, [key]));
2071
+ return matchPreferenceRow(result?.data ?? [], userId, key);
1802
2072
  };
1803
- const decodeValue = (value) => {
1804
- if (Array.isArray(value)) return value;
1805
- if (typeof value === "string") {
1806
- try {
1807
- const parsed = JSON.parse(value);
1808
- return Array.isArray(parsed) ? parsed : [];
1809
- } catch {
1810
- return [];
1811
- }
1812
- }
1813
- return [];
1814
- };
1815
- const upsert = async (items) => {
2073
+ const upsert = async (value) => {
1816
2074
  if (cachedRowId !== null) {
1817
2075
  try {
1818
- await dataSource.update(resource, cachedRowId, { value: items });
2076
+ await dataSource.update(resource, cachedRowId, { value });
1819
2077
  return;
1820
2078
  } catch (updateError) {
1821
2079
  cachedRowId = null;
@@ -1825,14 +2083,14 @@ function createObjectStackUserStateAdapter(options) {
1825
2083
  const existing = await findExisting();
1826
2084
  if (existing && existing.id !== void 0 && existing.id !== null) {
1827
2085
  cachedRowId = existing.id;
1828
- await dataSource.update(resource, existing.id, { value: items });
2086
+ await dataSource.update(resource, existing.id, { value });
1829
2087
  return;
1830
2088
  }
1831
2089
  try {
1832
2090
  const created = await dataSource.create(resource, {
1833
2091
  user_id: userId,
1834
2092
  key,
1835
- value: items
2093
+ value
1836
2094
  });
1837
2095
  const newId = created?.id;
1838
2096
  if (newId !== void 0 && newId !== null) cachedRowId = newId;
@@ -1840,21 +2098,74 @@ function createObjectStackUserStateAdapter(options) {
1840
2098
  const recovered = await findExisting();
1841
2099
  if (recovered && recovered.id !== void 0 && recovered.id !== null) {
1842
2100
  cachedRowId = recovered.id;
1843
- await dataSource.update(resource, recovered.id, { value: items });
2101
+ await dataSource.update(resource, recovered.id, { value });
1844
2102
  return;
1845
2103
  }
1846
2104
  throw createError;
1847
2105
  }
1848
2106
  };
2107
+ return {
2108
+ remember(id) {
2109
+ cachedRowId = id;
2110
+ },
2111
+ upsert,
2112
+ find: findExisting
2113
+ };
2114
+ }
2115
+ function matchPreferenceRow(rows, userId, key) {
2116
+ const match = rows.find((r) => {
2117
+ if (!r) return false;
2118
+ const userMismatch = r.user_id !== void 0 && r.user_id !== userId;
2119
+ const keyMismatch = r.key !== void 0 && r.key !== key;
2120
+ return !userMismatch && !keyMismatch;
2121
+ });
2122
+ return match ?? null;
2123
+ }
2124
+ function createObjectStackUserStateAdapter(options) {
2125
+ const {
2126
+ dataSource,
2127
+ userId,
2128
+ key,
2129
+ resource = DEFAULT_RESOURCE,
2130
+ onError = () => {
2131
+ }
2132
+ } = options;
2133
+ const writer = createPreferenceRowWriter(dataSource, resource, userId, key, onError);
2134
+ let saveChain = Promise.resolve();
2135
+ const loadExisting = async () => {
2136
+ const read = joinSharedRead(dataSource, resource, userId, { key });
2137
+ const { rows, saturated } = await read.answer;
2138
+ if (read.keys.size === 1 && read.prefixes.size === 0) {
2139
+ return matchPreferenceRow(rows, userId, key);
2140
+ }
2141
+ const own = rows.find(
2142
+ (r) => r && r.key === key && (r.user_id === void 0 || r.user_id === userId)
2143
+ );
2144
+ if (own) return own;
2145
+ if (saturated || rows.some((r) => r && r.key === void 0)) return writer.find();
2146
+ return null;
2147
+ };
2148
+ const decodeValue = (value) => {
2149
+ if (Array.isArray(value)) return value;
2150
+ if (typeof value === "string") {
2151
+ try {
2152
+ const parsed = JSON.parse(value);
2153
+ return Array.isArray(parsed) ? parsed : [];
2154
+ } catch {
2155
+ return [];
2156
+ }
2157
+ }
2158
+ return [];
2159
+ };
1849
2160
  return {
1850
2161
  async load() {
1851
2162
  try {
1852
- const row = await findExisting();
2163
+ const row = await loadExisting();
1853
2164
  if (!row) {
1854
- cachedRowId = null;
2165
+ writer.remember(null);
1855
2166
  return [];
1856
2167
  }
1857
- if (row.id !== void 0 && row.id !== null) cachedRowId = row.id;
2168
+ if (row.id !== void 0 && row.id !== null) writer.remember(row.id);
1858
2169
  return decodeValue(row.value);
1859
2170
  } catch (error) {
1860
2171
  onError("load", error);
@@ -1862,7 +2173,7 @@ function createObjectStackUserStateAdapter(options) {
1862
2173
  }
1863
2174
  },
1864
2175
  async save(items) {
1865
- const run = saveChain.then(() => upsert(items)).catch((error) => {
2176
+ const run = saveChain.then(() => writer.upsert(items)).catch((error) => {
1866
2177
  onError("save", error);
1867
2178
  });
1868
2179
  saveChain = run;
@@ -1870,6 +2181,69 @@ function createObjectStackUserStateAdapter(options) {
1870
2181
  }
1871
2182
  };
1872
2183
  }
2184
+ function createObjectStackUserPreferenceFamily(options) {
2185
+ const {
2186
+ dataSource,
2187
+ userId,
2188
+ prefix,
2189
+ resource = DEFAULT_RESOURCE,
2190
+ onError = () => {
2191
+ }
2192
+ } = options;
2193
+ const writers = /* @__PURE__ */ new Map();
2194
+ const chains = /* @__PURE__ */ new Map();
2195
+ const writerFor = (key) => {
2196
+ let writer = writers.get(key);
2197
+ if (!writer) {
2198
+ writer = createPreferenceRowWriter(dataSource, resource, userId, key, onError);
2199
+ writers.set(key, writer);
2200
+ }
2201
+ return writer;
2202
+ };
2203
+ const decodeValue = (value) => {
2204
+ let parsed = value;
2205
+ if (typeof value === "string") {
2206
+ try {
2207
+ parsed = JSON.parse(value);
2208
+ } catch {
2209
+ return void 0;
2210
+ }
2211
+ }
2212
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
2213
+ };
2214
+ return {
2215
+ prefix,
2216
+ async load() {
2217
+ try {
2218
+ const read = joinSharedRead(dataSource, resource, userId, { prefix });
2219
+ const { rows } = await read.answer;
2220
+ const out = {};
2221
+ for (const row of rows) {
2222
+ if (!row || typeof row.key !== "string" || !row.key.startsWith(prefix)) continue;
2223
+ if (row.user_id !== void 0 && row.user_id !== userId) continue;
2224
+ if (row.id !== void 0 && row.id !== null) writerFor(row.key).remember(row.id);
2225
+ const value = decodeValue(row.value);
2226
+ if (value !== void 0) out[row.key] = value;
2227
+ }
2228
+ return out;
2229
+ } catch (error) {
2230
+ onError("load", error);
2231
+ return {};
2232
+ }
2233
+ },
2234
+ async save(key, value) {
2235
+ if (!key.startsWith(prefix) || key.length === prefix.length) {
2236
+ onError("save", new Error(`key "${key}" is not in the family "${prefix}"`));
2237
+ return;
2238
+ }
2239
+ const run = (chains.get(key) ?? Promise.resolve()).then(() => writerFor(key).upsert(value)).catch((error) => {
2240
+ onError("save", error);
2241
+ });
2242
+ chains.set(key, run);
2243
+ return run;
2244
+ }
2245
+ };
2246
+ }
1873
2247
 
1874
2248
  // src/draft-envelope.ts
1875
2249
  var import_kernel = require("@objectstack/spec/kernel");
@@ -1882,20 +2256,61 @@ function extractDraftBody(resp) {
1882
2256
  if (Object.keys(body).length === 0) return null;
1883
2257
  return (0, import_kernel.stripReadDecorations)(body);
1884
2258
  }
1885
-
1886
- // src/metadata-error.ts
1887
- function extractIssues(e) {
1888
- const issues = e?.issues;
1889
- return Array.isArray(issues) ? issues : [];
2259
+ function readDraftChanges(resp) {
2260
+ if (!resp || typeof resp !== "object") return null;
2261
+ const changes = resp.draftChanges;
2262
+ return Array.isArray(changes) ? changes : null;
1890
2263
  }
1891
- function formatMetadataIssue(i) {
1892
- return `\u2022 ${i.path && i.path.length > 0 ? i.path : "(root)"} \u2014 ${i.message}`;
2264
+ function isPathPrefix(prefix, path) {
2265
+ if (prefix.length > path.length) return false;
2266
+ for (let i = 0; i < prefix.length; i++) if (prefix[i] !== path[i]) return false;
2267
+ return true;
1893
2268
  }
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);
2269
+ function governingDraftChange(changes, path) {
2270
+ let best = null;
2271
+ for (const change of changes ?? []) {
2272
+ if (!isPathPrefix(change.path, path)) continue;
2273
+ if (!best || change.path.length > best.path.length) best = change;
2274
+ }
2275
+ return best;
2276
+ }
2277
+ function draftChangesUnder(changes, path) {
2278
+ return (changes ?? []).filter((change) => change.path.length > path.length && isPathPrefix(path, change.path));
2279
+ }
2280
+
2281
+ // src/picklist-binding.ts
2282
+ function dropServedPicklistOptions(body) {
2283
+ if (!body || typeof body !== "object") return body;
2284
+ const fields = body.fields;
2285
+ if (Array.isArray(fields)) {
2286
+ let changed = false;
2287
+ const next = fields.map((def) => {
2288
+ const stripped = withoutServedOptions(def);
2289
+ if (stripped !== def) changed = true;
2290
+ return stripped;
2291
+ });
2292
+ return changed ? { ...body, fields: next } : body;
2293
+ }
2294
+ if (fields && typeof fields === "object") {
2295
+ let changed = false;
2296
+ const next = Object.fromEntries(
2297
+ Object.entries(fields).map(([name, def]) => {
2298
+ const stripped = withoutServedOptions(def);
2299
+ if (stripped !== def) changed = true;
2300
+ return [name, stripped];
2301
+ })
2302
+ );
2303
+ return changed ? { ...body, fields: next } : body;
2304
+ }
2305
+ return body;
2306
+ }
2307
+ function withoutServedOptions(def) {
2308
+ if (!def || typeof def !== "object" || Array.isArray(def)) return def;
2309
+ const record = def;
2310
+ if (record.picklist === void 0) return def;
2311
+ if (!Object.prototype.hasOwnProperty.call(record, "options")) return def;
2312
+ const { options: _served, ...authored } = record;
2313
+ return authored;
1899
2314
  }
1900
2315
 
1901
2316
  // src/index.ts
@@ -2173,6 +2588,7 @@ function serializeOrderBy(orderby) {
2173
2588
  }
2174
2589
  return void 0;
2175
2590
  }
2591
+ var VIEW_LIST_KEY = "list:view";
2176
2592
  var discoveryCache = /* @__PURE__ */ new Map();
2177
2593
  async function getSharedDiscovery(baseUrl, fetcher) {
2178
2594
  const key = baseUrl || "<default>";
@@ -2226,29 +2642,6 @@ function metaEnvelopeCarriesItem(envelope) {
2226
2642
  if (envelope == null || typeof envelope !== "object") return false;
2227
2643
  return envelope.item != null;
2228
2644
  }
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
- }
2252
2645
  var AnalyticsNotInstalledError = class extends Error {
2253
2646
  constructor(surface, detail, serverCode) {
2254
2647
  super(
@@ -2614,6 +3007,16 @@ function sameWireValue(a, b) {
2614
3007
  if (aEmpty || bEmpty) return aEmpty && bEmpty;
2615
3008
  return stableStringify(a) === stableStringify(b);
2616
3009
  }
3010
+ function readWriteWarnings(result) {
3011
+ const raw = result?.warnings;
3012
+ if (!Array.isArray(raw) || raw.length === 0) return [];
3013
+ const out = [];
3014
+ for (const entry of raw) {
3015
+ const parsed = import_api3.ValidateDataIssueSchema.safeParse(entry);
3016
+ if (parsed.success) out.push(parsed.data);
3017
+ }
3018
+ return out;
3019
+ }
2617
3020
  function withoutNoOpDrops(droppedFields, sent, stored) {
2618
3021
  if (!sent || !stored || typeof sent !== "object" || typeof stored !== "object") {
2619
3022
  return droppedFields;
@@ -2694,6 +3097,10 @@ function mergeViewPatch(current, partial, viewName, objectName) {
2694
3097
  object: current?.object || current?.data?.object || objectName
2695
3098
  };
2696
3099
  }
3100
+ function isCallerAbort(error, init) {
3101
+ if (init?.signal?.aborted) return true;
3102
+ return error?.name === "AbortError";
3103
+ }
2697
3104
  var ObjectStackAdapter = class {
2698
3105
  constructor(config) {
2699
3106
  __publicField(this, "client");
@@ -2717,6 +3124,13 @@ var ObjectStackAdapter = class {
2717
3124
  // multiple sibling components requesting the same dataset on first paint)
2718
3125
  // into a single network round trip.
2719
3126
  __publicField(this, "inflightFinds", /* @__PURE__ */ new Map());
3127
+ // In-flight findOne() reads keyed by resource + record id + serialized
3128
+ // params, shared the same way (objectui#11699). Opening one record page
3129
+ // asked for the same record twice at once: `record:details`' DetailView
3130
+ // re-runs its load effect while its first read is still on the wire. A
3131
+ // write to a resource drops that resource's entries (see `emitMutation`),
3132
+ // so a read asked after a save is never answered by one sent before it.
3133
+ __publicField(this, "inflightFindOnes", /* @__PURE__ */ new Map());
2720
3134
  // Resources that have responded 404 at least once (collection not installed
2721
3135
  // on this backend). Subsequent find() calls short-circuit to an empty result
2722
3136
  // so optional collections like sys_presence don't hammer the server with
@@ -2759,68 +3173,78 @@ var ObjectStackAdapter = class {
2759
3173
  // the opposite direction — those two describe a write that succeeded, this
2760
3174
  // one a read that did not happen.
2761
3175
  __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");
2767
- this.client = new import_client.ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
3176
+ const transport = config.fetch || globalThis.fetch.bind(globalThis);
3177
+ this.fetchImpl = (input, init) => this.observeServerAnswer(() => transport(input, init), init);
3178
+ this.client = new import_client.ObjectStackClient({
3179
+ ...config,
3180
+ fetch: this.fetchImpl,
3181
+ logger: createQuietHttpLogger()
3182
+ });
2768
3183
  this.installSaveAdvisoryInterceptor();
2769
3184
  this.metadataCache = new MetadataCache(config.cache);
3185
+ this.installViewListShare();
2770
3186
  this.autoReconnect = config.autoReconnect ?? true;
2771
3187
  this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3;
2772
3188
  this.reconnectDelay = config.reconnectDelay ?? 1e3;
2773
3189
  this.baseUrl = config.baseUrl;
2774
3190
  this.token = config.token;
2775
- this.systemCapabilities = config.systemCapabilities;
2776
- this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
2777
3191
  }
2778
3192
  /**
2779
3193
  * Ensure the client is connected to the server.
2780
3194
  * Call this before making requests or it will auto-connect on first request.
3195
+ *
3196
+ * With `autoReconnect`, a failed discovery read is retried up to
3197
+ * `maxReconnectAttempts` times with exponential backoff. The promise resolves
3198
+ * once discovery answers and rejects with the last `ConnectionError` once the
3199
+ * retries are spent (objectui#12318).
2781
3200
  */
2782
3201
  async connect() {
2783
3202
  if (this.connected) return;
2784
3203
  if (this.connectPromise) return this.connectPromise;
2785
3204
  this.setConnectionState("connecting");
2786
- this.connectPromise = (async () => {
2787
- try {
2788
- const baseUrl = this.baseUrl || "";
2789
- const discoveryUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/api/v1/discovery` : "/api/v1/discovery";
2790
- const data = await getSharedDiscovery(baseUrl, async () => {
2791
- const res = await this.fetchImpl(discoveryUrl, {
2792
- method: "GET",
2793
- headers: this.token ? { Authorization: `Bearer ${this.token}` } : void 0
2794
- });
2795
- if (!res.ok) {
2796
- throw new Error(`discovery ${res.status} ${res.statusText}`);
2797
- }
2798
- const body = await res.json();
2799
- return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
3205
+ this.connectPromise = this.attemptConnect().finally(() => {
3206
+ this.connectPromise = null;
3207
+ });
3208
+ return this.connectPromise;
3209
+ }
3210
+ /**
3211
+ * One discovery attempt, and the retry that follows it when it fails with
3212
+ * budget left. Runs inside the promise {@link connect} hands every caller.
3213
+ */
3214
+ async attemptConnect() {
3215
+ try {
3216
+ const baseUrl = this.baseUrl || "";
3217
+ const discoveryUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/api/v1/discovery` : "/api/v1/discovery";
3218
+ const data = await getSharedDiscovery(baseUrl, async () => {
3219
+ const res = await this.fetchImpl(discoveryUrl, {
3220
+ method: "GET",
3221
+ headers: this.token ? { Authorization: `Bearer ${this.token}` } : void 0
2800
3222
  });
2801
- this.client.discoveryInfo = data;
2802
- this.atomicBatchCapability = readTransactionalBatchCapability(data);
2803
- this.connected = true;
2804
- this.reconnectAttempts = 0;
2805
- this.setConnectionState("connected");
2806
- } catch (error) {
2807
- const errorMessage = error instanceof Error ? error.message : "Failed to connect to ObjectStack server";
2808
- const connectionError = new ConnectionError(
2809
- errorMessage,
2810
- void 0,
2811
- { originalError: error }
2812
- );
2813
- this.setConnectionState("error", connectionError);
2814
- if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
2815
- await this.attemptReconnect();
2816
- } else {
2817
- throw connectionError;
3223
+ if (!res.ok) {
3224
+ throw new Error(`discovery ${res.status} ${res.statusText}`);
2818
3225
  }
2819
- } finally {
2820
- this.connectPromise = null;
3226
+ const body = await res.json();
3227
+ return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
3228
+ });
3229
+ this.client.discoveryInfo = data;
3230
+ this.atomicBatchCapability = readTransactionalBatchCapability(data);
3231
+ this.connected = true;
3232
+ this.reconnectAttempts = 0;
3233
+ this.setConnectionState("connected");
3234
+ } catch (error) {
3235
+ const errorMessage = error instanceof Error ? error.message : "Failed to connect to ObjectStack server";
3236
+ const connectionError = new ConnectionError(
3237
+ errorMessage,
3238
+ void 0,
3239
+ { originalError: error }
3240
+ );
3241
+ this.setConnectionState("error", connectionError);
3242
+ if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
3243
+ await this.attemptReconnect();
3244
+ } else {
3245
+ throw connectionError;
2821
3246
  }
2822
- })();
2823
- return this.connectPromise;
3247
+ }
2824
3248
  }
2825
3249
  /**
2826
3250
  * Attempt to reconnect to the server with exponential backoff
@@ -2830,8 +3254,7 @@ var ObjectStackAdapter = class {
2830
3254
  this.setConnectionState("reconnecting");
2831
3255
  const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
2832
3256
  await new Promise((resolve) => setTimeout(resolve, delay));
2833
- this.connected = false;
2834
- await this.connect();
3257
+ await this.attemptConnect();
2835
3258
  }
2836
3259
  /**
2837
3260
  * Get the current connection state
@@ -2887,6 +3310,63 @@ var ObjectStackAdapter = class {
2887
3310
  }
2888
3311
  });
2889
3312
  }
3313
+ /**
3314
+ * Run one request through the adapter's fetch and let its outcome move the
3315
+ * connection state (objectui#12295). Before the first successful
3316
+ * {@link connect} this records nothing: `connect()` owns the state until
3317
+ * then, and its own discovery read passes through here too.
3318
+ *
3319
+ * - **Answered** — the fetch resolved with a Response, whatever its status.
3320
+ * A 4xx or 5xx is an answer: the server is reachable, so it counts the
3321
+ * same as a 200 and ends any outage.
3322
+ * - **Not answered** — the fetch rejected: no HTTP answer at all (refused,
3323
+ * reset, DNS, an offline browser). A rejection the caller asked for by
3324
+ * aborting is not evidence about the server and records nothing.
3325
+ *
3326
+ * The rejection or the Response reaches the caller unchanged.
3327
+ */
3328
+ async observeServerAnswer(send, init) {
3329
+ let response;
3330
+ try {
3331
+ response = await send();
3332
+ } catch (error) {
3333
+ if (!isCallerAbort(error, init)) this.noteServerUnanswered(error);
3334
+ throw error;
3335
+ }
3336
+ this.noteServerAnswered();
3337
+ return response;
3338
+ }
3339
+ /** An answered request after the first connect: the server is reachable. */
3340
+ noteServerAnswered() {
3341
+ if (!this.connected) return;
3342
+ this.reconnectAttempts = 0;
3343
+ if (this.connectionState !== "connected") this.setConnectionState("connected");
3344
+ }
3345
+ /**
3346
+ * An unanswered request after the first connect. It spends the adapter's own
3347
+ * retry window — the `autoReconnect` / `maxReconnectAttempts` budget that
3348
+ * {@link connect} grants a failed discovery — counted in the requests the
3349
+ * host already makes rather than in timed probes: while the budget lasts the
3350
+ * state is `reconnecting`, and the first unanswered request past it moves the
3351
+ * state to `error`. No timer and no request of the adapter's own is involved;
3352
+ * the next answered request, whenever it comes, restores `connected`.
3353
+ */
3354
+ noteServerUnanswered(error) {
3355
+ if (!this.connected || this.connectionState === "error") return;
3356
+ if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
3357
+ this.reconnectAttempts++;
3358
+ if (this.connectionState !== "reconnecting") this.setConnectionState("reconnecting");
3359
+ return;
3360
+ }
3361
+ this.setConnectionState(
3362
+ "error",
3363
+ new ConnectionError(
3364
+ error instanceof Error ? error.message : "ObjectStack server did not answer",
3365
+ void 0,
3366
+ { originalError: error }
3367
+ )
3368
+ );
3369
+ }
2890
3370
  /**
2891
3371
  * Emit batch progress event to listeners
2892
3372
  */
@@ -2908,9 +3388,7 @@ var ObjectStackAdapter = class {
2908
3388
  return { data: [], total: 0 };
2909
3389
  }
2910
3390
  const key = `${resource}::${stableStringify(params)}`;
2911
- const existing = this.inflightFinds.get(key);
2912
- if (existing) return existing;
2913
- const promise = (async () => {
3391
+ return this.shareInFlight(this.inflightFinds, key, async () => {
2914
3392
  await this.connect();
2915
3393
  if (params?.$expand && params.$expand.length > 0 || params?.$search != null && String(params.$search).trim() !== "") {
2916
3394
  const result = await this.rawFindWithPopulate(resource, params);
@@ -2927,11 +3405,25 @@ var ObjectStackAdapter = class {
2927
3405
  }
2928
3406
  throw err;
2929
3407
  }
2930
- })();
2931
- this.inflightFinds.set(key, promise);
3408
+ });
3409
+ }
3410
+ /**
3411
+ * Share one in-flight read among concurrent callers asking the same
3412
+ * question: the first call for `key` issues `read()`, every call that
3413
+ * arrives while it is still pending gets that same promise, and the entry is
3414
+ * dropped the moment it settles — so nothing is cached past the round trip,
3415
+ * a rejection reaches every caller that shared it and is not remembered, and
3416
+ * the next call after settle issues a fresh read. Used by `find` and
3417
+ * `findOne` (objectui#11699).
3418
+ */
3419
+ shareInFlight(inflight, key, read) {
3420
+ const existing = inflight.get(key);
3421
+ if (existing) return existing;
3422
+ const promise = read();
3423
+ inflight.set(key, promise);
2932
3424
  const cleanup = () => {
2933
- if (this.inflightFinds.get(key) === promise) {
2934
- this.inflightFinds.delete(key);
3425
+ if (inflight.get(key) === promise) {
3426
+ inflight.delete(key);
2935
3427
  }
2936
3428
  };
2937
3429
  promise.then(cleanup, cleanup);
@@ -3010,8 +3502,16 @@ var ObjectStackAdapter = class {
3010
3502
  }
3011
3503
  /**
3012
3504
  * Find a single record by ID.
3505
+ *
3506
+ * Concurrent calls for the same resource, id and params share one request,
3507
+ * exactly as `find` does (objectui#11699); see {@link shareInFlight}.
3013
3508
  */
3014
3509
  async findOne(resource, id, params) {
3510
+ const key = `${resource}::${JSON.stringify(String(id))}::${stableStringify(params)}`;
3511
+ return this.shareInFlight(this.inflightFindOnes, key, () => this.readOne(resource, id, params));
3512
+ }
3513
+ /** The one round trip behind {@link findOne}, unshared. */
3514
+ async readOne(resource, id, params) {
3015
3515
  await this.connect();
3016
3516
  if (params?.$expand && params.$expand.length > 0) {
3017
3517
  try {
@@ -3051,6 +3551,10 @@ var ObjectStackAdapter = class {
3051
3551
  * mutation or starve the other subscribers, so each is isolated.
3052
3552
  */
3053
3553
  emitMutation(event) {
3554
+ const prefix = `${event.resource}::`;
3555
+ for (const key of Array.from(this.inflightFindOnes.keys())) {
3556
+ if (key.startsWith(prefix)) this.inflightFindOnes.delete(key);
3557
+ }
3054
3558
  for (const listener of this.mutationListeners) {
3055
3559
  try {
3056
3560
  listener(event);
@@ -3095,15 +3599,28 @@ var ObjectStackAdapter = class {
3095
3599
  * reason routed to the skew arm, a non-string field element refused, a missing
3096
3600
  * `object` healed from `resource` — never asserted into the union, and the
3097
3601
  * entry itself never dropped for them (objectui#4934, `f75810e7c`).
3602
+ *
3603
+ * The same answer's `warnings` (objectui#12107) rides this one event: it is
3604
+ * emitted when either list is non-empty, so a write whose only news is an
3605
+ * advisory rule hit still reaches the subscriber, with `droppedFields: []`.
3098
3606
  */
3099
3607
  notifyDroppedFields(operation, resource, result, id, sent) {
3100
3608
  const dropped = result?.droppedFields;
3101
- if (!Array.isArray(dropped) || dropped.length === 0) return;
3102
- const valid = dropped.filter(isWireDroppedFieldsEntry).map((e) => asDroppedFieldsNotice(e, resource));
3103
- const stored = result?.record;
3104
- const droppedFields = withoutNoOpDrops(valid, sent, stored);
3105
- if (droppedFields.length === 0) return;
3106
- this.emitWriteWarning({ operation, resource, ...id !== void 0 ? { id } : {}, droppedFields });
3609
+ let droppedFields = [];
3610
+ if (Array.isArray(dropped) && dropped.length > 0) {
3611
+ const valid = dropped.filter(isWireDroppedFieldsEntry).map((e) => asDroppedFieldsNotice(e, resource));
3612
+ const stored = result?.record;
3613
+ droppedFields = withoutNoOpDrops(valid, sent, stored);
3614
+ }
3615
+ const warnings = readWriteWarnings(result);
3616
+ if (droppedFields.length === 0 && warnings.length === 0) return;
3617
+ this.emitWriteWarning({
3618
+ operation,
3619
+ resource,
3620
+ ...id !== void 0 ? { id } : {},
3621
+ droppedFields,
3622
+ ...warnings.length > 0 ? { warnings } : {}
3623
+ });
3107
3624
  }
3108
3625
  /**
3109
3626
  * Same, for the cross-object transactional batch (objectstack-ai/objectstack#3794). Its
@@ -3241,8 +3758,8 @@ var ObjectStackAdapter = class {
3241
3758
  *
3242
3759
  * `ObjectStackClient.meta.saveItem` is the second client class that writes
3243
3760
  * through `PUT /api/v1/meta/:type/:name`, and every one of its callers reaches
3244
- * it through an adapter this class constructed — the four inside this file
3245
- * (`updateViewConfig`, the two view paths, `updateDashboard`) via
3761
+ * it through an adapter this class constructed — the three inside this file
3762
+ * (the two view paths, `updateDashboard`) via
3246
3763
  * `this.client`, and every caller outside it via {@link getClient}, which
3247
3764
  * hands back this same instance: `MetadataService` (app-shell, five saves),
3248
3765
  * `useNavigationSync`, and plugin-designer's Create/EditAppPage. Wrapping the
@@ -3271,20 +3788,30 @@ var ObjectStackAdapter = class {
3271
3788
  *
3272
3789
  * Drafts are NEVER gated: the framework returns at its D1 early-return
3273
3790
  * (`if (args.state !== 'active') return null`) before running a rule, so a
3274
- * draft save produces no findings to withhold. This client class has no draft
3275
- * door at all to worry about — the SDK's `saveItem(type, name, item)` takes no
3276
- * mode and always writes the active door, which is exactly why the gate DOES
3277
- * run for its callers. `mode` on the emitted event is therefore derived from
3278
- * the response's own `state` rather than from a request-side flag that does
3279
- * not exist here: `'draft'` when the server says the row landed as a draft,
3280
- * `'publish'` otherwise. That keeps the event truthful about which door it
3281
- * came through instead of hard-coding one.
3791
+ * draft save produces no findings to withhold. `mode` on the emitted event is
3792
+ * derived from the response's own `state`, not from the caller's options:
3793
+ * `'draft'` when the server says the row landed as a draft, `'publish'`
3794
+ * otherwise. The server's answer is the one that says which door the write
3795
+ * took, so the event cannot claim a door the request only asked for.
3796
+ *
3797
+ * ## Every argument is forwarded (objectui#12300)
3798
+ *
3799
+ * The SDK's `saveItem(type, name, item, options?)` carries the write's scope
3800
+ * in its fourth argument, `SaveMetaItemOptions`: `packageId` (`?package=`),
3801
+ * `force`, `mode` and `ifMatch`. This wrapper used to name three parameters
3802
+ * and call the original with three, so that bag was dropped before the SDK
3803
+ * saw it. Every caller reaching the SDK through {@link getClient} got a bare
3804
+ * `PUT` whatever it passed, while the declared signature still accepted the
3805
+ * bag, so nothing at the call site could tell. The wrapper now takes and
3806
+ * passes the original's own parameter list, so it forwards whatever the SDK
3807
+ * declares, today's bag and any later member, without naming one.
3282
3808
  */
3283
3809
  installSaveAdvisoryInterceptor() {
3284
3810
  const meta = this.client.meta;
3285
3811
  const original = meta.saveItem.bind(meta);
3286
- meta.saveItem = async (type, name, item) => {
3287
- const result = await original(type, name, item);
3812
+ meta.saveItem = async (...args) => {
3813
+ const [type, name] = args;
3814
+ const result = await original(...args);
3288
3815
  try {
3289
3816
  const advisories = readSaveAdvisories(result);
3290
3817
  if (advisories.length > 0) {
@@ -3307,6 +3834,61 @@ var ObjectStackAdapter = class {
3307
3834
  return result;
3308
3835
  };
3309
3836
  }
3837
+ /**
3838
+ * Route this adapter's client's published view-list read through the one
3839
+ * {@link VIEW_LIST_KEY} (objectui#13044).
3840
+ *
3841
+ * The app-shell `MetadataProvider` reads `client.meta.getItems('view')`
3842
+ * through {@link getClient}; {@link listViews} and {@link listViewOverrides}
3843
+ * read the same `GET /api/v1/meta/view`. Each used to put its own request on
3844
+ * the wire, so one list-page open asked the server three times for one URL.
3845
+ *
3846
+ * A caller of the client keeps its contract, "ask the server": it is never
3847
+ * answered from a settled entry. It joins a request already in flight for
3848
+ * the same URL instead of sending a second one, and its answer is stored
3849
+ * under {@link VIEW_LIST_KEY} (see {@link MetadataCache.share}), which is
3850
+ * what {@link readViewList} answers this adapter's own readers from
3851
+ * (objectui#13057). Only the bare call is routed: any argument after the
3852
+ * type changes the URL, so such a call goes straight to the SDK.
3853
+ *
3854
+ * Every caller gets its own copy of the answer. Before this, every reader
3855
+ * got its own parsed response, so a reader that changed what it was handed
3856
+ * changed nothing anyone else read; a shared answer must not change that.
3857
+ *
3858
+ * Same wrap shape and the same bound as `installSaveAdvisoryInterceptor`:
3859
+ * `meta` is an own property of a client this adapter built and never shares.
3860
+ */
3861
+ installViewListShare() {
3862
+ const meta = this.client.meta;
3863
+ const original = meta.getItems.bind(meta);
3864
+ meta.getItems = ((...args) => {
3865
+ if (args[0] !== "view" || args.length > 1) return original(...args);
3866
+ return this.metadataCache.share(VIEW_LIST_KEY, () => original(...args)).then((answer) => structuredClone(answer));
3867
+ });
3868
+ for (const door of ["saveItem", "deleteItem", "publishItem", "rollbackItem"]) {
3869
+ const write = meta[door];
3870
+ if (typeof write !== "function") continue;
3871
+ const bound = write.bind(meta);
3872
+ meta[door] = (...args) => {
3873
+ if (args[0] !== "view") return bound(...args);
3874
+ return bound(...args).finally(() => this.metadataCache.invalidate?.(VIEW_LIST_KEY));
3875
+ };
3876
+ }
3877
+ }
3878
+ /**
3879
+ * The published view list for this adapter's own readers
3880
+ * ({@link listViews}, {@link listViewOverrides}): the answer stored under
3881
+ * {@link VIEW_LIST_KEY} while it holds one (objectui#13057), else the
3882
+ * client's read, which joins the request in flight for that key or starts
3883
+ * one, and stores its answer (objectui#13044). Always a copy the caller may
3884
+ * keep. Every path that should see a fresh list drops the key first; the
3885
+ * list is in {@link VIEW_LIST_KEY}'s doc.
3886
+ */
3887
+ readViewList() {
3888
+ const settled = this.metadataCache.getCachedSync(VIEW_LIST_KEY);
3889
+ if (settled !== void 0) return Promise.resolve(structuredClone(settled));
3890
+ return this.client.meta.getItems("view");
3891
+ }
3310
3892
  async create(resource, data) {
3311
3893
  await this.connect();
3312
3894
  try {
@@ -4064,7 +4646,6 @@ var ObjectStackAdapter = class {
4064
4646
  () => this.fetchObjectSchemaFresh(objectName)
4065
4647
  );
4066
4648
  normalizeSchemaReferenceKeys(schema);
4067
- this.applyFieldWidgetOverrides(objectName, schema);
4068
4649
  return schema;
4069
4650
  } catch (error) {
4070
4651
  const errorObj = error;
@@ -4077,44 +4658,6 @@ var ObjectStackAdapter = class {
4077
4658
  throw createErrorFromResponse(errorObj, `getObjectSchema(${objectName})`);
4078
4659
  }
4079
4660
  }
4080
- /**
4081
- * ADR-0056 P2 (epic #2398) — stamp structured-widget hints onto platform
4082
- * fields whose framework type is a storage primitive (e.g. `textarea`) but
4083
- * whose authoring UX should be a structured editor. Only the render `widget`
4084
- * is added; the field's `type` (the storage contract) is untouched. Applied
4085
- * idempotently to the cached schema so form + detail both honor it. Widget
4086
- * components are registered as `field:<widget>` in `@object-ui/fields`.
4087
- */
4088
- applyFieldWidgetOverrides(objectName, schema) {
4089
- const OVERRIDES = {
4090
- // ADR-0056 pure model — a permission set's six authorization facets are
4091
- // *designed* in Studio's structured editors and only *assigned* (to
4092
- // users) in Setup. In Setup they render read-only as a summary + a
4093
- // "Design in Studio →" deep-link (the `permission-facet-link` widget),
4094
- // never as raw [Object]/JSON. The capability *editor* itself lives in
4095
- // Studio (epic #2398 P2).
4096
- sys_permission_set: {
4097
- object_permissions: "permission-facet-link",
4098
- field_permissions: "permission-facet-link",
4099
- system_permissions: "permission-facet-link",
4100
- row_level_security: "permission-facet-link",
4101
- tab_permissions: "permission-facet-link",
4102
- admin_scope: "permission-facet-link"
4103
- }
4104
- };
4105
- const overrides = OVERRIDES[objectName];
4106
- const fields = schema && typeof schema === "object" ? schema.fields : null;
4107
- if (!overrides || !fields) return;
4108
- for (const [fname, widget] of Object.entries(overrides)) {
4109
- if (Array.isArray(fields)) {
4110
- const f = fields.find((x) => x?.name === fname);
4111
- if (f && !f.widget) f.widget = widget;
4112
- } else {
4113
- const f = fields[fname];
4114
- if (f && !f.widget) f.widget = widget;
4115
- }
4116
- }
4117
- }
4118
4661
  /**
4119
4662
  * Fetch a single object's schema while always revalidating the browser cache.
4120
4663
  *
@@ -4189,6 +4732,38 @@ var ObjectStackAdapter = class {
4189
4732
  return [];
4190
4733
  }
4191
4734
  }
4735
+ /**
4736
+ * List the items one metadata registry type declares —
4737
+ * `GET /api/v1/meta/:type`, through the client's `meta.getItems` — as
4738
+ * `{ name, label }` headers for the `position-ref` / `permission-ref` field
4739
+ * widgets (objectui#7611).
4740
+ *
4741
+ * Unlike {@link getObjects}, a failure REJECTS (the `DataSource` contract):
4742
+ * the transport's own error propagates, and an answer without the spec's
4743
+ * `items` list is refused rather than read as an empty registry, so a
4744
+ * failed load can never reach the widget as "this registry declares
4745
+ * nothing". Uncached, like `getObjects`: an item authored in Studio is
4746
+ * listed on the next form that opens.
4747
+ *
4748
+ * An item without a string `name` is not listed (the widget stores the
4749
+ * name, so there is nothing to store); a `label` that is not a string is
4750
+ * left off rather than coerced.
4751
+ */
4752
+ async listMetadataItems(type) {
4753
+ await this.connect();
4754
+ const result = await this.client.meta.getItems(type);
4755
+ if (!Array.isArray(result?.items)) {
4756
+ throw new Error(`GET /meta/${type} answered without an items list`);
4757
+ }
4758
+ const out = [];
4759
+ for (const it of result.items) {
4760
+ const name = it?.name;
4761
+ if (typeof name !== "string" || !name) continue;
4762
+ const label = it.label;
4763
+ out.push(typeof label === "string" && label ? { name, label } : { name });
4764
+ }
4765
+ return out;
4766
+ }
4192
4767
  /**
4193
4768
  * Get access to the underlying ObjectStack client for advanced operations.
4194
4769
  */
@@ -4255,14 +4830,16 @@ var ObjectStackAdapter = class {
4255
4830
  invalidateViewKeys(objectName, viewName) {
4256
4831
  this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
4257
4832
  this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
4833
+ this.metadataCache.invalidate?.(VIEW_LIST_KEY);
4258
4834
  }
4259
4835
  /**
4260
4836
  * Batch-fetch all persisted view overrides for an object.
4261
4837
  *
4262
4838
  * Per-view runtime overrides (density, column widths, sort, hidden
4263
- * columns, inlineEdit …) live in the SAME metadata namespace the
4264
- * write path uses: `type='view'`, `name=<viewId>` (see
4265
- * {@link updateViewConfig}). Loading them per-view fires N HTTP GETs
4839
+ * columns, inlineEdit …) live in the SAME metadata namespace the view
4840
+ * writes use: `type='view'`, `name=<viewId>` (see {@link createView} /
4841
+ * {@link updateView}; the toolbar's `updateViewConfig` wrote overlays there
4842
+ * until objectui#12504 retired it). Loading them per-view fires N HTTP GETs
4266
4843
  * that 404 for every view the user never customized — console noise on
4267
4844
  * every page load. This batch method performs a single
4268
4845
  * `GET /api/v1/meta/view` (returns `{type, items}`) and narrows the
@@ -4286,9 +4863,9 @@ var ObjectStackAdapter = class {
4286
4863
  *
4287
4864
  * Result is cached identically to {@link getView}, and EVERY view write —
4288
4865
  * on this adapter and above it — invalidates it through the one seam,
4289
- * {@link invalidateViewKeys}. For a long time only {@link updateViewConfig}
4290
- * did (objectui#4363), which left the other three adapter paths stale
4291
- * for the cache's 5-minute TTL. That gap does not self-heal: the consumer
4866
+ * {@link invalidateViewKeys}. For a long time only `updateViewConfig` (since
4867
+ * retired, objectui#12504) did (objectui#4363), which left the other three
4868
+ * adapter paths stale for the cache's 5-minute TTL. That gap does not self-heal: the consumer
4292
4869
  * (`loadViewOverrides`, app-shell `ObjectView`) treats a RESOLVED map as
4293
4870
  * authoritative and deliberately does not re-probe per view — objectui#3774,
4294
4871
  * and correct, since re-probing reinstates the 404 flurry the batch read
@@ -4304,7 +4881,7 @@ var ObjectStackAdapter = class {
4304
4881
  await this.connect();
4305
4882
  const cacheKey = `view-overrides:${objectName}`;
4306
4883
  return await this.metadataCache.get(cacheKey, async () => {
4307
- const result = await this.client.meta.getItems("view");
4884
+ const result = await this.readViewList();
4308
4885
  const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
4309
4886
  const out = {};
4310
4887
  for (const it of items) {
@@ -4339,104 +4916,6 @@ var ObjectStackAdapter = class {
4339
4916
  return null;
4340
4917
  }
4341
4918
  }
4342
- /**
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}.
4361
- *
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).
4376
- *
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.
4380
- *
4381
- * @param objectName - Object name (e.g. 'lead')
4382
- * @param viewId - View identifier (e.g. 'all_leads')
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.
4387
- */
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
- }
4424
- await this.connect();
4425
- const merged = {
4426
- ...config || {},
4427
- object: config?.object || objectName,
4428
- name: viewId,
4429
- ...opts?.isSavedView ? {} : { [VIEW_OVERLAY_MARKER]: true }
4430
- };
4431
- const result = await this.client.meta.saveItem(
4432
- "view",
4433
- viewId,
4434
- merged
4435
- );
4436
- this.invalidateViewKeys(objectName, viewId);
4437
- if (result && result.item) return result.item;
4438
- return result ?? void 0;
4439
- }
4440
4919
  /**
4441
4920
  * List user-created views for a given object via the metadata overlay
4442
4921
  * API (ADR-0005). Replaces the legacy `find('sys_view', {...})` path
@@ -4493,7 +4972,7 @@ var ObjectStackAdapter = class {
4493
4972
  const draftItems = await this.metadataClient().withPreviewDrafts(true).list("view");
4494
4973
  items = Array.isArray(draftItems) ? draftItems : [];
4495
4974
  } else {
4496
- const result = await this.client.meta.getItems("view");
4975
+ const result = await this.readViewList();
4497
4976
  items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
4498
4977
  }
4499
4978
  const FORM_FAMILY = /* @__PURE__ */ new Set(["form", "detail"]);
@@ -4782,6 +5261,10 @@ var ObjectStackAdapter = class {
4782
5261
  * the PUBLISHED half after the draft was discarded carries the partial
4783
5262
  * state on the error's `outcome` — "draft gone, overlay left" is exactly
4784
5263
  * what the old `{ deleted: boolean }` could not express.
5264
+ * Its message is the adapter's "NOT fully removed" sentence followed, on
5265
+ * the next line, by the server's own reason (objectui#12416), so
5266
+ * `formatMetadataError(err)` shows the remedy a refusal names; the
5267
+ * original error stays on `cause`.
4785
5268
  *
4786
5269
  * Invalidates through {@link invalidateViewKeys}: the deleted row leaves the
4787
5270
  * batch override map too, and a ghost entry there is what the object page
@@ -4807,9 +5290,12 @@ var ObjectStackAdapter = class {
4807
5290
  draft,
4808
5291
  published: { removed: false }
4809
5292
  };
5293
+ const status = err?.status;
5294
+ const refused = typeof status === "number" && status >= 400 && status < 500;
4810
5295
  throw Object.assign(
4811
5296
  new Error(
4812
- `deleteView: view "${viewName}" on object "${objectName}" is NOT fully removed \u2014 the draft home was ${draft.removed ? "discarded" : "already absent"}, but deleting the published overlay failed. The published row is still serving the view; retry the delete.`
5297
+ `deleteView: view "${viewName}" on object "${objectName}" is NOT fully removed \u2014 the draft home was ${draft.removed ? "discarded" : "already absent"}, but deleting the published overlay failed. The published row is still serving the view${refused ? "." : "; retry the delete."}
5298
+ ` + formatMetadataError(err)
4813
5299
  ),
4814
5300
  { cause: err, outcome }
4815
5301
  );
@@ -4909,14 +5395,41 @@ var ObjectStackAdapter = class {
4909
5395
  * @param appName - the app name as it appears in the URL segment
4910
5396
  */
4911
5397
  async probeAppAccess(appName) {
4912
- if (!appName) return "unknown";
5398
+ return (await this.probeMetaItemAccess("app", appName)).verdict;
5399
+ }
5400
+ /**
5401
+ * {@link probeAppAccess} for any metadata type whose by-name read refuses a
5402
+ * caller (objectui#12112): why `name` is not in this session's list of
5403
+ * `type`, plus the server's own sentence when it refused.
5404
+ *
5405
+ * Since objectstack#22731 a dashboard and a list view carry the app's gate:
5406
+ * `GET /api/v1/meta/dashboard` and `/meta/view` list only what the caller may
5407
+ * open, so a withheld item and a missing one are the same absence there, and
5408
+ * the by-name read of one the caller does not hold answers `403`
5409
+ * `PERMISSION_DENIED`, "the app arm's whole refusal (same emitter, same
5410
+ * envelope)". The verdicts are therefore read exactly as for an app.
5411
+ *
5412
+ * `message` is the refusal's `message`, as the server wrote it: the one
5413
+ * sentence that names the item and says it was refused. Only on `denied`.
5414
+ *
5415
+ * Nothing here throws, and nothing is cached: a verdict about the CALLER
5416
+ * must not outlive a grant.
5417
+ *
5418
+ * @param type - the singular metadata type (`app`, `dashboard`, `view`)
5419
+ * @param name - the item's name, as the by-name route addresses it
5420
+ */
5421
+ async probeMetaItemAccess(type, name) {
5422
+ if (!type || !name) return { verdict: "unknown" };
4913
5423
  try {
4914
- const envelope = await this.client.meta.getItem("app", appName);
4915
- return metaEnvelopeCarriesItem(envelope) ? "granted" : "not_found";
5424
+ const envelope = await this.client.meta.getItem(type, name);
5425
+ return { verdict: metaEnvelopeCarriesItem(envelope) ? "granted" : "not_found" };
4916
5426
  } catch (err) {
4917
- if (isAppPermissionDeniedError(err)) return "denied";
4918
- if (isMetaItemAbsentError(err)) return "not_found";
4919
- return "unreachable";
5427
+ if (isAppPermissionDeniedError(err)) {
5428
+ const message = err?.message;
5429
+ return typeof message === "string" && message.trim() ? { verdict: "denied", message } : { verdict: "denied" };
5430
+ }
5431
+ if (isMetaItemAbsentError(err)) return { verdict: "not_found" };
5432
+ return { verdict: "unreachable" };
4920
5433
  }
4921
5434
  }
4922
5435
  /**
@@ -5004,12 +5517,9 @@ var ObjectStackAdapter = class {
5004
5517
  throw new AnalyticsResultShapeError(resource, contractResult);
5005
5518
  }
5006
5519
  const rawRows = contractResult.rows;
5007
- const measureMissing = rawRows.length > 0 && rawRows.every((row) => {
5008
- if (row == null) return true;
5009
- if (measureName in row && row[measureName] != null) return false;
5010
- if (valueKey in row && row[valueKey] != null) return false;
5011
- return true;
5012
- });
5520
+ const measureMissing = rawRows.length > 0 && rawRows.every(
5521
+ (row) => row == null || !(measureName in row) && !(valueKey in row)
5522
+ );
5013
5523
  if (measureMissing) {
5014
5524
  return await this.aggregateViaFind(resource, params);
5015
5525
  }
@@ -5461,13 +5971,12 @@ function createObjectStackAdapter(config) {
5461
5971
  OBJECT_METADATA_TYPE,
5462
5972
  ObjectStackAdapter,
5463
5973
  ObjectStackError,
5974
+ PREFERENCE_FAMILY_ROW_LIMIT,
5464
5975
  RELATIONSHIP_TYPES_REQUIRING_REFERENCE,
5465
5976
  UNRECOGNIZED_DROP_REASON,
5466
5977
  UnlowerableAnalyticsFilterError,
5467
5978
  UnloweredAggregateWhereError,
5468
- VIEW_CONFIG_CAPABILITY,
5469
5979
  VIEW_OVERLAY_OWNED_KEYS,
5470
- ViewConfigPermissionDeniedError,
5471
5980
  assertObjectMetadataWritable,
5472
5981
  classifyAnalyticsFailure,
5473
5982
  classifyImportMappingsFailure,
@@ -5475,13 +5984,17 @@ function createObjectStackAdapter(config) {
5475
5984
  clearSharedDiscoveryCache,
5476
5985
  createErrorFromResponse,
5477
5986
  createObjectStackAdapter,
5987
+ createObjectStackUserPreferenceFamily,
5478
5988
  createObjectStackUserStateAdapter,
5479
5989
  createQuietHttpLogger,
5990
+ draftChangesUnder,
5991
+ dropServedPicklistOptions,
5480
5992
  extractDraftBody,
5481
5993
  formatHttpFailureMessage,
5482
5994
  formatMetadataError,
5483
5995
  formatMetadataIssue,
5484
5996
  getSharedDiscovery,
5997
+ governingDraftChange,
5485
5998
  is404Error,
5486
5999
  isAnalyticsNotInstalledError,
5487
6000
  isApiAccessDeniedError,
@@ -5491,9 +6004,9 @@ function createObjectStackAdapter(config) {
5491
6004
  isMalformedFilterError,
5492
6005
  isMetaItemAbsentError,
5493
6006
  isObjectStackError,
5494
- isViewConfigPermissionDeniedError,
5495
6007
  narrowPersonalizationOverlay,
5496
6008
  normaliseClientError,
6009
+ readDraftChanges,
5497
6010
  readSaveAdvisories,
5498
6011
  readTransactionalBatchCapability,
5499
6012
  serializeOrderBy,