@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/CHANGELOG.md +368 -0
- package/README.md +172 -2
- package/dist/index.cjs +875 -362
- package/dist/index.d.cts +742 -307
- package/dist/index.d.ts +742 -307
- package/dist/index.js +870 -360
- package/package.json +5 -5
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
|
465
|
-
|
|
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, {
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
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
|
|
1718
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2091
|
+
const row = await loadExisting();
|
|
1784
2092
|
if (!row) {
|
|
1785
|
-
|
|
2093
|
+
writer.remember(null);
|
|
1786
2094
|
return [];
|
|
1787
2095
|
}
|
|
1788
|
-
if (row.id !== void 0 && row.id !== null)
|
|
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
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
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
|
|
1823
|
-
|
|
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
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
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
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
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 = (
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
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
|
-
|
|
2733
|
-
|
|
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
|
-
|
|
2751
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
2865
|
-
|
|
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
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
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
|
|
3176
|
-
* (
|
|
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.
|
|
3206
|
-
*
|
|
3207
|
-
*
|
|
3208
|
-
*
|
|
3209
|
-
* the
|
|
3210
|
-
*
|
|
3211
|
-
*
|
|
3212
|
-
*
|
|
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 (
|
|
3218
|
-
const
|
|
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
|
-
*
|
|
4196
|
-
* {@link
|
|
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
|
|
4221
|
-
* did (objectui#4363), which left the other three
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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))
|
|
4849
|
-
|
|
4850
|
-
|
|
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(
|
|
4939
|
-
|
|
4940
|
-
|
|
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,
|