@object-ui/data-objectstack 17.6.0 → 17.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2092 -0
- package/README.md +557 -11
- package/dist/index.cjs +1419 -124
- package/dist/index.d.cts +1423 -98
- package/dist/index.d.ts +1423 -98
- package/dist/index.js +1408 -125
- package/package.json +9 -8
package/dist/index.cjs
CHANGED
|
@@ -25,28 +25,46 @@ __export(index_exports, {
|
|
|
25
25
|
API_ACCESS_DENIED_CODES: () => API_ACCESS_DENIED_CODES,
|
|
26
26
|
APP_PERMISSION_DENIED_CODE: () => APP_PERMISSION_DENIED_CODE,
|
|
27
27
|
AnalyticsDatasetNotFoundError: () => AnalyticsDatasetNotFoundError,
|
|
28
|
+
AnalyticsForbiddenError: () => AnalyticsForbiddenError,
|
|
29
|
+
AnalyticsKeysOnSpecShapeError: () => AnalyticsKeysOnSpecShapeError,
|
|
28
30
|
AnalyticsNotInstalledError: () => AnalyticsNotInstalledError,
|
|
29
31
|
AnalyticsQueryRejectedError: () => AnalyticsQueryRejectedError,
|
|
32
|
+
AnalyticsResultShapeError: () => AnalyticsResultShapeError,
|
|
30
33
|
AnalyticsUnauthenticatedError: () => AnalyticsUnauthenticatedError,
|
|
31
34
|
AuthenticationError: () => AuthenticationError,
|
|
32
35
|
BulkOperationError: () => BulkOperationError,
|
|
36
|
+
CHOICE_TYPES_REQUIRING_OPTIONS: () => CHOICE_TYPES_REQUIRING_OPTIONS,
|
|
33
37
|
ConcurrentUpdateError: () => ConcurrentUpdateError,
|
|
34
38
|
ConnectionError: () => ConnectionError,
|
|
35
39
|
DataApiValidationError: () => DataApiValidationError,
|
|
36
40
|
FILTER_OPERATOR_ALIASES: () => FILTER_OPERATOR_ALIASES,
|
|
41
|
+
META_ITEM_ABSENT_CODE: () => META_ITEM_ABSENT_CODE,
|
|
37
42
|
MalformedFilterError: () => MalformedFilterError,
|
|
38
43
|
MetadataClient: () => MetadataClient,
|
|
39
44
|
MetadataNotFoundError: () => MetadataNotFoundError,
|
|
45
|
+
OBJECT_METADATA_TYPE: () => OBJECT_METADATA_TYPE,
|
|
40
46
|
ObjectStackAdapter: () => ObjectStackAdapter,
|
|
41
47
|
ObjectStackError: () => ObjectStackError,
|
|
48
|
+
RELATIONSHIP_TYPES_REQUIRING_REFERENCE: () => RELATIONSHIP_TYPES_REQUIRING_REFERENCE,
|
|
49
|
+
UNRECOGNIZED_DROP_REASON: () => UNRECOGNIZED_DROP_REASON,
|
|
50
|
+
UnlowerableAnalyticsFilterError: () => UnlowerableAnalyticsFilterError,
|
|
51
|
+
UnloweredAggregateWhereError: () => UnloweredAggregateWhereError,
|
|
52
|
+
VIEW_CONFIG_CAPABILITY: () => VIEW_CONFIG_CAPABILITY,
|
|
42
53
|
VIEW_OVERLAY_OWNED_KEYS: () => VIEW_OVERLAY_OWNED_KEYS,
|
|
54
|
+
ViewConfigPermissionDeniedError: () => ViewConfigPermissionDeniedError,
|
|
55
|
+
assertObjectMetadataWritable: () => assertObjectMetadataWritable,
|
|
43
56
|
classifyAnalyticsFailure: () => classifyAnalyticsFailure,
|
|
57
|
+
classifyImportMappingsFailure: () => classifyImportMappingsFailure,
|
|
58
|
+
classifyViewsFailure: () => classifyViewsFailure,
|
|
44
59
|
clearSharedDiscoveryCache: () => clearSharedDiscoveryCache,
|
|
45
60
|
createErrorFromResponse: () => createErrorFromResponse,
|
|
46
61
|
createObjectStackAdapter: () => createObjectStackAdapter,
|
|
47
62
|
createObjectStackUserStateAdapter: () => createObjectStackUserStateAdapter,
|
|
48
63
|
createQuietHttpLogger: () => createQuietHttpLogger,
|
|
64
|
+
extractDraftBody: () => extractDraftBody,
|
|
49
65
|
formatHttpFailureMessage: () => formatHttpFailureMessage,
|
|
66
|
+
formatMetadataError: () => formatMetadataError,
|
|
67
|
+
formatMetadataIssue: () => formatMetadataIssue,
|
|
50
68
|
getSharedDiscovery: () => getSharedDiscovery,
|
|
51
69
|
is404Error: () => is404Error,
|
|
52
70
|
isAnalyticsNotInstalledError: () => isAnalyticsNotInstalledError,
|
|
@@ -55,7 +73,9 @@ __export(index_exports, {
|
|
|
55
73
|
isConcurrentUpdateError: () => isConcurrentUpdateError,
|
|
56
74
|
isErrorType: () => isErrorType,
|
|
57
75
|
isMalformedFilterError: () => isMalformedFilterError,
|
|
76
|
+
isMetaItemAbsentError: () => isMetaItemAbsentError,
|
|
58
77
|
isObjectStackError: () => isObjectStackError,
|
|
78
|
+
isViewConfigPermissionDeniedError: () => isViewConfigPermissionDeniedError,
|
|
59
79
|
narrowPersonalizationOverlay: () => narrowPersonalizationOverlay,
|
|
60
80
|
normaliseClientError: () => normaliseClientError,
|
|
61
81
|
readSaveAdvisories: () => readSaveAdvisories,
|
|
@@ -65,6 +85,71 @@ __export(index_exports, {
|
|
|
65
85
|
});
|
|
66
86
|
module.exports = __toCommonJS(index_exports);
|
|
67
87
|
var import_client = require("@objectstack/client");
|
|
88
|
+
var import_ui2 = require("@objectstack/spec/ui");
|
|
89
|
+
var import_data4 = require("@objectstack/spec/data");
|
|
90
|
+
var import_data5 = require("@objectstack/spec/data");
|
|
91
|
+
var import_data6 = require("@objectstack/spec/data");
|
|
92
|
+
var import_kernel2 = require("@objectstack/spec/kernel");
|
|
93
|
+
|
|
94
|
+
// src/metadata-client.ts
|
|
95
|
+
var import_api = require("@objectstack/spec/api");
|
|
96
|
+
|
|
97
|
+
// src/object-metadata-write-guard.ts
|
|
98
|
+
var RELATIONSHIP_TYPES_REQUIRING_REFERENCE = ["lookup", "master_detail"];
|
|
99
|
+
var CHOICE_TYPES_REQUIRING_OPTIONS = ["select", "radio"];
|
|
100
|
+
var OBJECT_METADATA_TYPE = "object";
|
|
101
|
+
function isUsableTarget(reference) {
|
|
102
|
+
return typeof reference === "string" && reference.trim() !== "";
|
|
103
|
+
}
|
|
104
|
+
function describeTarget(reference) {
|
|
105
|
+
if (reference === void 0) return "no `reference` key at all";
|
|
106
|
+
if (reference === null) return "a `null` `reference`";
|
|
107
|
+
if (typeof reference !== "string") return `a \`reference\` of type \`${typeof reference}\``;
|
|
108
|
+
if (reference === "") return "an empty `reference`";
|
|
109
|
+
return "a whitespace-only `reference`";
|
|
110
|
+
}
|
|
111
|
+
function hasOptionSource(def) {
|
|
112
|
+
return Array.isArray(def.options) && def.options.length > 0 || typeof def.picklist === "string";
|
|
113
|
+
}
|
|
114
|
+
function describeOptions(options) {
|
|
115
|
+
if (options === void 0) return "no `options` key and no `picklist`";
|
|
116
|
+
if (Array.isArray(options)) return "an empty `options` list and no `picklist`";
|
|
117
|
+
if (options === null) return "a `null` `options` and no `picklist`";
|
|
118
|
+
return `an \`options\` value of type \`${typeof options}\` and no \`picklist\``;
|
|
119
|
+
}
|
|
120
|
+
function fieldEntries(fields) {
|
|
121
|
+
if (Array.isArray(fields)) {
|
|
122
|
+
return fields.flatMap((raw, index) => {
|
|
123
|
+
if (!raw || typeof raw !== "object") return [];
|
|
124
|
+
const record = raw;
|
|
125
|
+
const name = typeof record.name === "string" && record.name ? record.name : `[${index}]`;
|
|
126
|
+
return [{ name, def: record }];
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (fields && typeof fields === "object") {
|
|
130
|
+
return Object.entries(fields).flatMap(
|
|
131
|
+
([name, def]) => def && typeof def === "object" ? [{ name, def }] : []
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
function assertObjectMetadataWritable(type, item, writer) {
|
|
137
|
+
if (String(type) !== OBJECT_METADATA_TYPE) return;
|
|
138
|
+
if (!item || typeof item !== "object") return;
|
|
139
|
+
const fields = item.fields;
|
|
140
|
+
for (const { name, def } of fieldEntries(fields)) {
|
|
141
|
+
if (CHOICE_TYPES_REQUIRING_OPTIONS.includes(String(def.type)) && !hasOptionSource(def)) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`${writer} refused this object metadata write: the field \`${name}\` is a \`${String(def.type)}\` with no options: it carries ${describeOptions(def.options)}, so it offers nothing to choose and nothing to check a stored value against. A choice field needs at least one option or a shared \`picklist\` (objectstack#20827), so this draft stays here until it has one. Add an option, or change the field to a non-choice type.`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (!RELATIONSHIP_TYPES_REQUIRING_REFERENCE.includes(String(def.type))) continue;
|
|
147
|
+
if (isUsableTarget(def.reference)) continue;
|
|
148
|
+
throw new Error(
|
|
149
|
+
`${writer} refused this object metadata write: the field \`${name}\` is a \`${String(def.type)}\` and carries ${describeTarget(def.reference)}, so it names no object to link to. \`@objectstack/spec\` refuses the same document at the server with a 422 on \`fields.${name}.reference\`, and that refusal blocks every later save of this object for as long as the half-filled field rides along in the draft (objectui#7714). Pick the target object, or change the field to a non-relationship type.`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
68
153
|
|
|
69
154
|
// src/metadata-client.ts
|
|
70
155
|
function readSaveAdvisories(body) {
|
|
@@ -77,6 +162,25 @@ function readSaveAdvisories(body) {
|
|
|
77
162
|
return typeof c.rule === "string" && typeof c.path === "string" && typeof c.where === "string" && typeof c.message === "string" && typeof c.hint === "string" && typeof c.severity === "string";
|
|
78
163
|
});
|
|
79
164
|
}
|
|
165
|
+
var PROTECTION_ENVELOPE_KEYS = [
|
|
166
|
+
"overlayScope",
|
|
167
|
+
"lock",
|
|
168
|
+
"lockReason",
|
|
169
|
+
"lockSource",
|
|
170
|
+
"lockDocsUrl",
|
|
171
|
+
"provenance",
|
|
172
|
+
"packageId",
|
|
173
|
+
"packageVersion",
|
|
174
|
+
"editable",
|
|
175
|
+
"deletable",
|
|
176
|
+
"resettable"
|
|
177
|
+
];
|
|
178
|
+
function unrecognizedEnvelopeKeys(body) {
|
|
179
|
+
const shape = import_api.GetMetaItemLayeredResponseSchema.shape;
|
|
180
|
+
return PROTECTION_ENVELOPE_KEYS.filter(
|
|
181
|
+
(key) => body[key] !== void 0 && !shape[key].safeParse(body[key]).success
|
|
182
|
+
);
|
|
183
|
+
}
|
|
80
184
|
var META_PREFIX = "/meta";
|
|
81
185
|
var API_PREFIX = "/api/v1";
|
|
82
186
|
function buildBase(config) {
|
|
@@ -89,6 +193,16 @@ function buildBase(config) {
|
|
|
89
193
|
}
|
|
90
194
|
return `${trimmed}${scoped}`;
|
|
91
195
|
}
|
|
196
|
+
function packagesBaseOf(metaBase) {
|
|
197
|
+
const origin = metaBase.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, "");
|
|
198
|
+
return `${origin}${API_PREFIX}/packages`;
|
|
199
|
+
}
|
|
200
|
+
function unwrapDispatcherEnvelope(body) {
|
|
201
|
+
if (!body || typeof body !== "object") return {};
|
|
202
|
+
const root = body;
|
|
203
|
+
const data = root.data;
|
|
204
|
+
return data && typeof data === "object" && !Array.isArray(data) ? data : root;
|
|
205
|
+
}
|
|
92
206
|
async function parseError(res) {
|
|
93
207
|
let body;
|
|
94
208
|
try {
|
|
@@ -121,7 +235,7 @@ var MetadataClient = class _MetadataClient {
|
|
|
121
235
|
__publicField(this, "headers");
|
|
122
236
|
/** ADR-0037: when true, reads render the draft-overlaid world. */
|
|
123
237
|
__publicField(this, "previewDrafts");
|
|
124
|
-
/** #4133 — sink for
|
|
238
|
+
/** #4133 / #5026 — sink for every write door's advisory findings; see the config field. */
|
|
125
239
|
__publicField(this, "onSaveAdvisory");
|
|
126
240
|
this.base = buildBase(config);
|
|
127
241
|
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
@@ -275,8 +389,20 @@ var MetadataClient = class _MetadataClient {
|
|
|
275
389
|
* {@link get} is deliberate and long-standing (the draft envelope's identity
|
|
276
390
|
* and protection carriers are part of what a draft reader inspects), so it
|
|
277
391
|
* is preserved by reading the transport directly instead of going through
|
|
278
|
-
* `get()`'s unwrap.
|
|
279
|
-
*
|
|
392
|
+
* `get()`'s unwrap.
|
|
393
|
+
*
|
|
394
|
+
* ⛔ Do NOT hand `.item` to a gate or a write yourself: the body this serves
|
|
395
|
+
* is DECORATED (`_draft`, then `_diagnostics` from `decorateMetadataItem`),
|
|
396
|
+
* and the spec says a served body is not a valid input to the schema that
|
|
397
|
+
* produced it until those come off. {@link extractDraftBody} is the one
|
|
398
|
+
* reader for this method's answer — it takes the presence verdict and then
|
|
399
|
+
* the strip, in that order. That function used to be copy-pasted into four
|
|
400
|
+
* views and only one copy knew the rule (objectui#8181).
|
|
401
|
+
*
|
|
402
|
+
* Its tolerant siblings exist for the OTHER client method: `unwrapDraftBody`
|
|
403
|
+
* (app-shell) and `unwrapViewDraft` (this package) read a draft that arrived
|
|
404
|
+
* through {@link get}, which already unwrapped the envelope. They split on
|
|
405
|
+
* which method feeds them, not on what they mean — all three strip.
|
|
280
406
|
*/
|
|
281
407
|
async getDraft(type, name, options = {}) {
|
|
282
408
|
return this.readItemResponse(type, name, {
|
|
@@ -284,6 +410,31 @@ var MetadataClient = class _MetadataClient {
|
|
|
284
410
|
...options.packageId ? { packageId: options.packageId } : {}
|
|
285
411
|
});
|
|
286
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* Report the runtime authoring gate's advisory findings for a write the
|
|
415
|
+
* server has ALREADY committed (#4133 for the save door, #5026 for the
|
|
416
|
+
* publish door).
|
|
417
|
+
*
|
|
418
|
+
* Everything here is best-effort by construction, and that is the whole
|
|
419
|
+
* contract: the row is committed server-side before this runs, so neither a
|
|
420
|
+
* malformed body, nor a missing key, nor a throwing sink may change what the
|
|
421
|
+
* calling method returns or whether it throws. The server emits `advisories`
|
|
422
|
+
* ONLY when non-empty, so a clean write costs one absent-key check.
|
|
423
|
+
*
|
|
424
|
+
* One helper rather than a copy per door: every door's response declares the
|
|
425
|
+
* key identically, so a second inline copy could only ever drift. The batch
|
|
426
|
+
* door (`ce986aafc`) calls it once per `published[]` element, handing it
|
|
427
|
+
* the element — the object that carries the key there — and the same
|
|
428
|
+
* best-effort contract covers that loop unchanged.
|
|
429
|
+
*/
|
|
430
|
+
emitAdvisories(body, event) {
|
|
431
|
+
if (!this.onSaveAdvisory) return;
|
|
432
|
+
try {
|
|
433
|
+
const advisories = readSaveAdvisories(body);
|
|
434
|
+
if (advisories.length > 0) this.onSaveAdvisory({ ...event, advisories });
|
|
435
|
+
} catch {
|
|
436
|
+
}
|
|
437
|
+
}
|
|
287
438
|
/**
|
|
288
439
|
* Save (PUT) a metadata item. The framework accepts both the bare
|
|
289
440
|
* item payload and the `{ item: ... }` / `{ metadata: ... }`
|
|
@@ -296,6 +447,7 @@ var MetadataClient = class _MetadataClient {
|
|
|
296
447
|
`MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
|
|
297
448
|
);
|
|
298
449
|
}
|
|
450
|
+
assertObjectMetadataWritable(type, item, "MetadataClient.save");
|
|
299
451
|
const params = [];
|
|
300
452
|
if (options.force) params.push("force=true");
|
|
301
453
|
if (options.mode === "draft") params.push("mode=draft");
|
|
@@ -314,20 +466,12 @@ var MetadataClient = class _MetadataClient {
|
|
|
314
466
|
});
|
|
315
467
|
if (!res.ok) throw await parseError(res);
|
|
316
468
|
const body = await res.json();
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
name,
|
|
324
|
-
mode: options.mode === "draft" ? "draft" : "publish",
|
|
325
|
-
advisories
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
} catch {
|
|
329
|
-
}
|
|
330
|
-
}
|
|
469
|
+
this.emitAdvisories(body, {
|
|
470
|
+
type,
|
|
471
|
+
name,
|
|
472
|
+
door: "save",
|
|
473
|
+
mode: options.mode === "draft" ? "draft" : "publish"
|
|
474
|
+
});
|
|
331
475
|
return body;
|
|
332
476
|
}
|
|
333
477
|
/**
|
|
@@ -341,6 +485,61 @@ var MetadataClient = class _MetadataClient {
|
|
|
341
485
|
* the rows and reports under `seedApplied` — a data problem never fails the
|
|
342
486
|
* publish, so callers should check `seedApplied?.success` and warn the user
|
|
343
487
|
* rather than assume the data went live.
|
|
488
|
+
*
|
|
489
|
+
* Same door as {@link publish} (`POST /meta/:type/:name/publish`), so it
|
|
490
|
+
* reports the gate's advisories the same way (#5026).
|
|
491
|
+
*
|
|
492
|
+
* The BATCH door (`POST /packages/:id/publish-drafts`) is a different route
|
|
493
|
+
* with a method of its own — {@link publishPackageDrafts} — and it reports
|
|
494
|
+
* too. It did not when this paragraph was first written: it discarded
|
|
495
|
+
* per-draft advisories server-side, which was objectstack#9343, and that card
|
|
496
|
+
* has since landed with a ruling that each `published[]` element carries
|
|
497
|
+
* them (`ce986aafc` is the client half).
|
|
498
|
+
*
|
|
499
|
+
* What survives that change is the READING RULE, which is about this method
|
|
500
|
+
* rather than about the other route: it reads the top level of the
|
|
501
|
+
* single-item body and nothing else. A batch-shaped body arriving HERE still
|
|
502
|
+
* reports nothing, because `PublishMetaItemResponse` declares no
|
|
503
|
+
* `published[]` and a client that went looking for findings wherever they
|
|
504
|
+
* might be would be inventing a contract instead of reading one.
|
|
505
|
+
*
|
|
506
|
+
* ## Why there is no `{ success, data }` unwrapping here (`7b433197d`)
|
|
507
|
+
*
|
|
508
|
+
* This method used to unwrap a dispatcher-shaped envelope before returning,
|
|
509
|
+
* while {@link publish} — the same route, a couple of hundred lines down —
|
|
510
|
+
* did not. Two spellings of one wire contract, and nothing said which was
|
|
511
|
+
* right. The tolerance is gone because the route was MEASURED, at the
|
|
512
|
+
* producer rather than inferred from the response schema:
|
|
513
|
+
*
|
|
514
|
+
* - The only mount of `POST /api/v1/meta/:type/:name/publish` is the REST
|
|
515
|
+
* server's own route-manager registration, which answers
|
|
516
|
+
* `res.json(await protocol.publishMetaItem(...))` — the protocol's object
|
|
517
|
+
* verbatim, with no envelope branch. The project-scoped mirror
|
|
518
|
+
* (`/api/v1/environments/:environmentId/...`) re-mounts that same handler.
|
|
519
|
+
* - The `{ success, data }` envelope has exactly one producer, the runtime
|
|
520
|
+
* `HttpDispatcher`, and it does not serve this route: its `/meta` domain
|
|
521
|
+
* has no publish branch, the dispatcher's own route ledger has no row for
|
|
522
|
+
* it, and a three-segment `/meta` path that is not `/published` terminates
|
|
523
|
+
* in a located `routeNotFound`. So no composition — REST server, the
|
|
524
|
+
* dispatcher-only Hono adapter, or both — puts an envelope on this body.
|
|
525
|
+
* - `packages/spec` states the same split in the two docstrings side by
|
|
526
|
+
* side: `PublishMetaItemResponseSchema` describes "the FULL body" this
|
|
527
|
+
* route returns and records that the REST route hands the producer's
|
|
528
|
+
* object to `res.json()` verbatim, while its batch sibling
|
|
529
|
+
* `PublishPackageDraftsResponseSchema` describes a body answered
|
|
530
|
+
* explicitly "inside the dispatcher's `{ success, data }` envelope".
|
|
531
|
+
*
|
|
532
|
+
* ⛔ So an enveloped body on THIS route is not a shape the platform emits,
|
|
533
|
+
* and unwrapping one is not a kindness: it is Commandment #0.1's lenient
|
|
534
|
+
* fallback, a second de-facto contract for a route that declares one. It
|
|
535
|
+
* also fails in the direction that hides the problem — a body that arrived
|
|
536
|
+
* enveloped is one this door did not actually serve, and unwrapping it
|
|
537
|
+
* would present that as a successful promotion.
|
|
538
|
+
*
|
|
539
|
+
* ⚠️ Sibling tolerances in this file are NOT this defect and must stay:
|
|
540
|
+
* {@link listDrafts} reads `data?.data?.drafts` because `GET /meta/_drafts`
|
|
541
|
+
* IS a dispatcher route, and `publishHealthFromResponse` unwraps because the
|
|
542
|
+
* batch door really is enveloped. The rule is per-route, not per-file.
|
|
344
543
|
*/
|
|
345
544
|
async publishDraft(type, name) {
|
|
346
545
|
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
|
|
@@ -351,8 +550,80 @@ var MetadataClient = class _MetadataClient {
|
|
|
351
550
|
});
|
|
352
551
|
if (!res.ok) throw await parseError(res);
|
|
353
552
|
const body = await res.json().catch(() => ({}));
|
|
354
|
-
|
|
355
|
-
return
|
|
553
|
+
this.emitAdvisories(body, { type, name, door: "publish", mode: "publish" });
|
|
554
|
+
return body;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Publish EVERY pending draft bound to one package — Studio's "publish whole
|
|
558
|
+
* app" door, `POST /api/v1/packages/:id/publish-drafts` (ADR-0033; the route
|
|
559
|
+
* that orders structure-before-seeds server-side and runs the ADR-0038 L3
|
|
560
|
+
* runtime probes).
|
|
561
|
+
*
|
|
562
|
+
* ## Why it is expressed here at all (`ce986aafc`)
|
|
563
|
+
*
|
|
564
|
+
* It was not, and that was the defect. Two app-shell call sites fired this
|
|
565
|
+
* route with a bare `fetch` / a page-private `apiJson`, outside the seam that
|
|
566
|
+
* covers every other metadata write — so when objectstack#9343 landed and the
|
|
567
|
+
* server began sending per-draft advisories, the author publishing a whole
|
|
568
|
+
* app was told nothing, while the SAME button's client-side capability lint
|
|
569
|
+
* still raised a toast. A door that cannot report is not a door the gate can
|
|
570
|
+
* reach, however loudly the server speaks.
|
|
571
|
+
*
|
|
572
|
+
* ## What it reports, and what it refuses to invent
|
|
573
|
+
*
|
|
574
|
+
* `PublishPackageDraftsResponseSchema` declares `advisories` on EACH
|
|
575
|
+
* `published[]` element — objectstack#9343's ruled shape, the same element
|
|
576
|
+
* type and the same omitted-when-empty discipline as the single-item door,
|
|
577
|
+
* and explicitly not a parallel top-level map. So this method emits one
|
|
578
|
+
* {@link MetadataSaveAdvisoryEvent} per advised element, each naming that
|
|
579
|
+
* element's own `type` / `name`, through the same sink, event and renderer
|
|
580
|
+
* the save and single-item publish doors use.
|
|
581
|
+
*
|
|
582
|
+
* ⛔ It renders only what the server sent where the schema says it sits:
|
|
583
|
+
*
|
|
584
|
+
* - `advisories` is read off the element, by the one shared reader, which
|
|
585
|
+
* drops anything that is not a complete finding.
|
|
586
|
+
* - An element that does not carry the `type` and `name` the schema states as
|
|
587
|
+
* REQUIRED is skipped rather than reported under invented identity — the
|
|
588
|
+
* event names the item the author has to go fix, and an event that cannot
|
|
589
|
+
* name it truthfully is worse than silence.
|
|
590
|
+
* - Nothing is derived, counted or summarised from the batch: no advisory
|
|
591
|
+
* exists here that the server did not put in the body.
|
|
592
|
+
*
|
|
593
|
+
* ## The failure shapes stay the callers'
|
|
594
|
+
*
|
|
595
|
+
* Non-2xx throws {@link MetadataError}, like every other method. A 2xx is
|
|
596
|
+
* RETURNED unexamined — `success: false` is not a failure on this route
|
|
597
|
+
* (`outcome: 'nothing_to_publish'` answers it too, and the spec's own text
|
|
598
|
+
* says to read `outcome`, not the boolean), and the two callers have their
|
|
599
|
+
* own, different rules for the refusal and rolled-back cases. Deciding that
|
|
600
|
+
* here would change behaviour this card is not about.
|
|
601
|
+
*/
|
|
602
|
+
async publishPackageDrafts(packageId) {
|
|
603
|
+
if (!packageId || !String(packageId).trim()) {
|
|
604
|
+
throw new Error(
|
|
605
|
+
"MetadataClient.publishPackageDrafts: packageId must be non-empty. The POST /packages/:id/publish-drafts route requires an id segment."
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
const url = `${packagesBaseOf(this.base)}/${encodeURIComponent(packageId)}/publish-drafts`;
|
|
609
|
+
const res = await this.fetchImpl(url, {
|
|
610
|
+
method: "POST",
|
|
611
|
+
// Both call sites this replaces sent the session cookie, and the client's
|
|
612
|
+
// own fetch adds the console's Bearer token — so the routed call carries a
|
|
613
|
+
// superset of what the bare doors carried, never less.
|
|
614
|
+
credentials: "include",
|
|
615
|
+
headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
|
|
616
|
+
body: "{}"
|
|
617
|
+
});
|
|
618
|
+
if (!res.ok) throw await parseError(res);
|
|
619
|
+
const body = unwrapDispatcherEnvelope(await res.json().catch(() => ({})));
|
|
620
|
+
for (const element of Array.isArray(body.published) ? body.published : []) {
|
|
621
|
+
if (!element || typeof element !== "object") continue;
|
|
622
|
+
const { type, name } = element;
|
|
623
|
+
if (typeof type !== "string" || typeof name !== "string") continue;
|
|
624
|
+
this.emitAdvisories(element, { type, name, door: "publish", mode: "publish" });
|
|
625
|
+
}
|
|
626
|
+
return body;
|
|
356
627
|
}
|
|
357
628
|
/**
|
|
358
629
|
* Get the 3-state layered view of a metadata item: `code` (the packaged
|
|
@@ -394,7 +665,7 @@ var MetadataClient = class _MetadataClient {
|
|
|
394
665
|
}
|
|
395
666
|
if (!res.ok) throw await parseError(res);
|
|
396
667
|
const body = await res.json();
|
|
397
|
-
const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
|
|
668
|
+
const hasEnvelope = typeof body === "object" && body !== null && ("code" in body || "overlay" in body || "effective" in body);
|
|
398
669
|
if (!hasEnvelope) {
|
|
399
670
|
return {
|
|
400
671
|
code: null,
|
|
@@ -403,14 +674,34 @@ var MetadataClient = class _MetadataClient {
|
|
|
403
674
|
effective: body
|
|
404
675
|
};
|
|
405
676
|
}
|
|
677
|
+
const parsed = import_api.GetMetaItemLayeredResponseSchema.safeParse(body);
|
|
678
|
+
if (parsed.success) {
|
|
679
|
+
const envelope = parsed.data;
|
|
680
|
+
return {
|
|
681
|
+
code: envelope.code ?? null,
|
|
682
|
+
overlay: envelope.overlay ?? null,
|
|
683
|
+
overlayScope: envelope.overlayScope,
|
|
684
|
+
effective: envelope.effective ?? null,
|
|
685
|
+
...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
|
|
686
|
+
lock: envelope.lock,
|
|
687
|
+
...envelope.lockReason !== void 0 ? { lockReason: envelope.lockReason } : {},
|
|
688
|
+
...envelope.lockSource !== void 0 ? { lockSource: envelope.lockSource } : {},
|
|
689
|
+
...envelope.lockDocsUrl !== void 0 ? { lockDocsUrl: envelope.lockDocsUrl } : {},
|
|
690
|
+
...envelope.provenance !== void 0 ? { provenance: envelope.provenance } : {},
|
|
691
|
+
...envelope.packageId !== void 0 ? { packageId: envelope.packageId } : {},
|
|
692
|
+
...envelope.packageVersion !== void 0 ? { packageVersion: envelope.packageVersion } : {},
|
|
693
|
+
editable: envelope.editable,
|
|
694
|
+
deletable: envelope.deletable,
|
|
695
|
+
resettable: envelope.resettable
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
const unrecognized = unrecognizedEnvelopeKeys(body);
|
|
406
699
|
return {
|
|
407
700
|
code: body.code ?? null,
|
|
408
701
|
overlay: body.overlay ?? null,
|
|
409
702
|
overlayScope: body.overlayScope ?? null,
|
|
410
703
|
effective: body.effective ?? null,
|
|
411
704
|
...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
|
|
412
|
-
// ADR-0010 Phase 4 — pass through the metadata-protection envelope so
|
|
413
|
-
// the editor can render lock affordances without a second round trip.
|
|
414
705
|
...body.lock !== void 0 ? { lock: body.lock } : {},
|
|
415
706
|
...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
|
|
416
707
|
...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
|
|
@@ -420,7 +711,8 @@ var MetadataClient = class _MetadataClient {
|
|
|
420
711
|
...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
|
|
421
712
|
...body.editable !== void 0 ? { editable: body.editable } : {},
|
|
422
713
|
...body.deletable !== void 0 ? { deletable: body.deletable } : {},
|
|
423
|
-
...body.resettable !== void 0 ? { resettable: body.resettable } : {}
|
|
714
|
+
...body.resettable !== void 0 ? { resettable: body.resettable } : {},
|
|
715
|
+
...unrecognized.length > 0 ? { _unrecognized: unrecognized } : {}
|
|
424
716
|
};
|
|
425
717
|
}
|
|
426
718
|
/**
|
|
@@ -487,9 +779,16 @@ var MetadataClient = class _MetadataClient {
|
|
|
487
779
|
* `{ success, version, seq, message }`. Throws a `404 no_draft` if
|
|
488
780
|
* nothing is pending and `409 metadata_conflict` if the published
|
|
489
781
|
* overlay moved while the draft was sitting.
|
|
782
|
+
*
|
|
783
|
+
* Reports the runtime authoring gate's advisory findings through
|
|
784
|
+
* {@link MetadataClientConfig.onSaveAdvisory} when the response carries them
|
|
785
|
+
* (#5026 / objectstack#9176), with `door: 'publish'`. This is the door the
|
|
786
|
+
* Studio designer takes on every edit, so it is the one whose findings an
|
|
787
|
+
* author actually sees.
|
|
490
788
|
*/
|
|
491
789
|
async publish(type, name, options = {}) {
|
|
492
|
-
const
|
|
790
|
+
const qs = options.packageId ? `?package=${encodeURIComponent(options.packageId)}` : "";
|
|
791
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish${qs}`;
|
|
493
792
|
const headers = {
|
|
494
793
|
...this.headers,
|
|
495
794
|
"Content-Type": "application/json"
|
|
@@ -500,7 +799,9 @@ var MetadataClient = class _MetadataClient {
|
|
|
500
799
|
body: JSON.stringify(options.message ? { message: options.message } : {})
|
|
501
800
|
});
|
|
502
801
|
if (!res.ok) throw await parseError(res);
|
|
503
|
-
|
|
802
|
+
const body = await res.json();
|
|
803
|
+
this.emitAdvisories(body, { type, name, door: "publish", mode: "publish" });
|
|
804
|
+
return body;
|
|
504
805
|
}
|
|
505
806
|
/**
|
|
506
807
|
* Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
|
|
@@ -592,14 +893,32 @@ function errorCodeIsAnyOf(err, codes) {
|
|
|
592
893
|
|
|
593
894
|
// ../core/src/utils/filter-converter.ts
|
|
594
895
|
var import_ui = require("@objectstack/spec/ui");
|
|
896
|
+
var import_data2 = require("@objectstack/spec/data");
|
|
897
|
+
|
|
898
|
+
// ../core/src/utils/text-comparand.ts
|
|
899
|
+
var import_data = require("@objectstack/spec/data");
|
|
900
|
+
|
|
901
|
+
// ../core/src/utils/filter-converter.ts
|
|
595
902
|
var FilterOperatorError = class extends Error {
|
|
596
|
-
constructor(message) {
|
|
903
|
+
constructor(message, subject) {
|
|
597
904
|
super(message);
|
|
598
905
|
__publicField(this, "code", "INVALID_FILTER");
|
|
599
906
|
__publicField(this, "httpStatus", 400);
|
|
907
|
+
/** @see FilterRefusalSubject */
|
|
908
|
+
__publicField(this, "operator");
|
|
909
|
+
/** @see FilterRefusalSubject */
|
|
910
|
+
__publicField(this, "field");
|
|
600
911
|
this.name = "FilterOperatorError";
|
|
912
|
+
this.operator = subject?.operator;
|
|
913
|
+
this.field = subject?.field;
|
|
601
914
|
}
|
|
602
915
|
};
|
|
916
|
+
var RETIRED_OPERATOR_ALIASES = {
|
|
917
|
+
"$notin": "$nin",
|
|
918
|
+
"$notcontains": "$notContains",
|
|
919
|
+
"$startswith": "$startsWith",
|
|
920
|
+
"$endswith": "$endsWith"
|
|
921
|
+
};
|
|
603
922
|
function convertOperatorToAST(operator) {
|
|
604
923
|
const operatorMap = {
|
|
605
924
|
"$eq": "=",
|
|
@@ -610,27 +929,148 @@ function convertOperatorToAST(operator) {
|
|
|
610
929
|
"$lte": "<=",
|
|
611
930
|
"$in": "in",
|
|
612
931
|
"$nin": "nin",
|
|
613
|
-
"$notin": "nin",
|
|
614
932
|
"$between": "between",
|
|
615
933
|
"$contains": "contains",
|
|
616
934
|
"$notContains": "notcontains",
|
|
617
|
-
"$notcontains": "notcontains",
|
|
618
935
|
"$startsWith": "startswith",
|
|
619
|
-
"$startswith": "startswith",
|
|
620
936
|
"$endsWith": "endswith",
|
|
621
|
-
|
|
937
|
+
// Case-insensitive contains. A canonical `FILTER_OPERATORS` member that
|
|
938
|
+
// `ValueDataSource` executes and `FilterConditionField` emits (for its
|
|
939
|
+
// `icontains` builder row — spelled `containsCaseInsensitive` until
|
|
940
|
+
// objectui#9306), while this map refused it with the
|
|
941
|
+
// generic unknown-operator paragraph — so ONE authored filter selected rows
|
|
942
|
+
// through the in-memory matcher and 400'd on the ObjectStack lowering path
|
|
943
|
+
// (objectui#8976). The other direction of the same split objectui#8568 fixed:
|
|
944
|
+
// there the map was MORE tolerant than the matcher, here it was LESS tolerant
|
|
945
|
+
// than the contract. Restored by aligning the map with the spec, per AGENTS.md
|
|
946
|
+
// #0 — not by removing the operator from the builder, which the spec declares.
|
|
947
|
+
//
|
|
948
|
+
// The VALUE is an IDENTITY, and that is not a typo: `icontains` is itself a
|
|
949
|
+
// member of the spec's `VALID_AST_OPERATORS`, so unlike its camelCase siblings
|
|
950
|
+
// above there is no case to squash. Same identity row, for the same stated
|
|
951
|
+
// reason, that `FILTER_OPERATOR_ALIASES` carries in
|
|
952
|
+
// `packages/data-objectstack/src/index.ts`.
|
|
953
|
+
"$icontains": "icontains"
|
|
622
954
|
};
|
|
623
955
|
return operatorMap[operator] || null;
|
|
624
956
|
}
|
|
957
|
+
var AST_LOGIC_KEYWORD = {
|
|
958
|
+
$and: "and",
|
|
959
|
+
$or: "or"
|
|
960
|
+
};
|
|
961
|
+
function lowerLogicalGroup(field, keyword, value) {
|
|
962
|
+
if (!Array.isArray(value)) {
|
|
963
|
+
throw new FilterOperatorError(
|
|
964
|
+
`[ObjectUI] The '${field}' filter combinator takes an ARRAY of conditions. Received ${typeof value === "object" ? "an object" : typeof value}: ${JSON.stringify(value)}. Spec: FilterCondition declares '${field}?: FilterCondition[]' (data/filter.zod.ts).`,
|
|
965
|
+
{ operator: field }
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
if (value.length === 0) {
|
|
969
|
+
return keyword === "and" ? void 0 : falseIdentityLeaf(field, value);
|
|
970
|
+
}
|
|
971
|
+
const children = [];
|
|
972
|
+
let absorbed = false;
|
|
973
|
+
for (const child of value) {
|
|
974
|
+
if (child === null || typeof child !== "object" || Array.isArray(child)) {
|
|
975
|
+
throw new FilterOperatorError(
|
|
976
|
+
`[ObjectUI] Every member of '${field}' must be a filter condition OBJECT. Received ${JSON.stringify(child)}. Spec: FilterCondition declares '${field}?: FilterCondition[]' (data/filter.zod.ts).`,
|
|
977
|
+
{ operator: field }
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
const lowered = convertFiltersToAST(child);
|
|
981
|
+
if (!Array.isArray(lowered)) {
|
|
982
|
+
if (keyword === "or") absorbed = true;
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
children.push(lowered);
|
|
986
|
+
}
|
|
987
|
+
if (absorbed) return void 0;
|
|
988
|
+
if (children.length === 0) return void 0;
|
|
989
|
+
return [keyword, ...children];
|
|
990
|
+
}
|
|
991
|
+
function falseIdentityLeaf(field, value) {
|
|
992
|
+
return [field, "=", value];
|
|
993
|
+
}
|
|
994
|
+
function isOperatorMap(value) {
|
|
995
|
+
const proto = Object.getPrototypeOf(value);
|
|
996
|
+
return proto === null || proto === Object.prototype || Object.getPrototypeOf(proto) === null;
|
|
997
|
+
}
|
|
998
|
+
function describeExoticComparand(value) {
|
|
999
|
+
let name;
|
|
1000
|
+
let text2;
|
|
1001
|
+
try {
|
|
1002
|
+
const proto = Object.getPrototypeOf(value);
|
|
1003
|
+
name = proto?.constructor?.name;
|
|
1004
|
+
} catch {
|
|
1005
|
+
name = void 0;
|
|
1006
|
+
}
|
|
1007
|
+
if (typeof name !== "string" || name === "") return "an object of an unrecognised type";
|
|
1008
|
+
try {
|
|
1009
|
+
text2 = String(value);
|
|
1010
|
+
} catch {
|
|
1011
|
+
text2 = void 0;
|
|
1012
|
+
}
|
|
1013
|
+
return text2 && text2 !== `[object ${name}]` ? `a ${name} instance (${text2})` : `a ${name} instance`;
|
|
1014
|
+
}
|
|
1015
|
+
function refuseTextComparand(field, operator, target) {
|
|
1016
|
+
const ported = `It is refused here rather than lowered onto the wire (objectui#9001; ported from ValueDataSource's refuseTextComparand, objectui#8748).`;
|
|
1017
|
+
throw new FilterOperatorError(
|
|
1018
|
+
`[ObjectUI] The ${(0, import_data.textComparandRefusalReason)(field, operator, target)}. ${ported}`,
|
|
1019
|
+
{ operator, field }
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
function refuseEmptyOperatorMap(field) {
|
|
1023
|
+
throw new FilterOperatorError(
|
|
1024
|
+
`[ObjectUI] The filter on field '${field}' is an EMPTY operator map \u2014 { ${field}: {} } names the field and no operator. It cannot be lowered: the ObjectQL filter AST has no node for a field with zero operators, and '@objectstack/spec' ruled the shape REJECTED wherever it appears (objectstack#5240, recorded on FilterConditionSchema). It is refused here rather than dropped: beside a key that lowers, the '${field}' condition used to vanish from the filter and silently WIDEN the result set (objectui#10788), and alone it was handed back as the caller's object, which reached the wire as ?${field}=[object Object] on one route and as a filter the server refuses on the other (objectui#9164). Choose an operator \u2014 { ${field}: { $eq: ... } }, { ${field}: { $in: [...] } }, { ${field}: { $null: true } } \u2014 or remove the key.`,
|
|
1025
|
+
{ field }
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
625
1028
|
function convertFiltersToAST(filter) {
|
|
626
1029
|
const conditions = [];
|
|
1030
|
+
let trueIdentityGroups = 0;
|
|
1031
|
+
let skippedNullKeys = 0;
|
|
627
1032
|
for (const [field, value] of Object.entries(filter)) {
|
|
628
|
-
if (value === null || value === void 0)
|
|
1033
|
+
if (value === null || value === void 0) {
|
|
1034
|
+
skippedNullKeys += 1;
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
const logicKeyword = AST_LOGIC_KEYWORD[field];
|
|
1038
|
+
if (logicKeyword) {
|
|
1039
|
+
const group = lowerLogicalGroup(field, logicKeyword, value);
|
|
1040
|
+
if (group !== void 0) conditions.push(group);
|
|
1041
|
+
else trueIdentityGroups += 1;
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
if (field === "$not") {
|
|
1045
|
+
throw new FilterOperatorError(
|
|
1046
|
+
`[ObjectUI] The '$not' filter combinator cannot be lowered to the ObjectQL filter AST. '@objectstack/spec' declares it on FilterCondition, but the AST this layer emits has no negation keyword (FILTER_ARRAY_LOGIC_KEYWORDS is ['and', 'or']), and rewriting the negation inward is not available either \u2014 'startswith', 'endswith', 'between' and 'icontains' have no negated counterpart in VALID_AST_OPERATORS, so the rewrite would be silently partial. Express the negation with a negated operator instead ($ne, $nin, $notContains); note those follow each operator's own answer for a missing value rather than $not's NULL-safe rule (objectstack#5146). Value: ${JSON.stringify(value)}.`,
|
|
1047
|
+
{ operator: "$not" }
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
if (Array.isArray(value)) {
|
|
1051
|
+
throw new FilterOperatorError(
|
|
1052
|
+
`[ObjectUI] The filter on field '${field}' carries a bare ARRAY as its equality comparand: ${JSON.stringify(value)}. It cannot be lowered: the ObjectQL filter AST has no array-equality node, so the lowered node [${field}, '=', [...]] is refused by @objectstack/driver-sql (400 INVALID_FILTER) and matches no row in the in-memory matchers \u2014 it can never select anything. It is deliberately NOT read as membership here: { ${field}: [...] } and { ${field}: { $in: [...] } } are different statements, and guessing the second from the first would silently change which rows a stored view returns (objectui#8530; the same ruling objectui#8514 applied one layer down). Spell membership as { ${field}: { $in: [...] } }, its negation as { ${field}: { $nin: [...] } }, or a range as { ${field}: { $between: [min, max] } }.`,
|
|
1053
|
+
{ field }
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
629
1056
|
if (typeof value === "object" && !Array.isArray(value)) {
|
|
630
|
-
|
|
1057
|
+
if ((0, import_data2.isAcceptedFilterComparand)(value)) {
|
|
1058
|
+
conditions.push([field, "=", value]);
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
if (!isOperatorMap(value)) {
|
|
1062
|
+
throw new FilterOperatorError(
|
|
1063
|
+
`[ObjectUI] The filter on field '${field}' carries ${describeExoticComparand(value)} in comparand position. '@objectstack/spec' does not accept it: a comparison value must be ${import_data2.ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (ACCEPTED_FILTER_COMPARAND_TYPES), so isAcceptedFilterComparand is false for it and normalizeFilterComparandTypes answers 400 INVALID_FILTER \u2014 "which no driver can compare". It is not an operator map either: only a plain object can be one, so until objectui#8567 this value was read as ZERO operators and the '${field}' condition was dropped from the filter entirely, silently WIDENING the result set. Spell a text match as { ${field}: { $contains: '...' } } ($startsWith / $endsWith; a pattern itself has no operator here, see the $regex refusal), a membership test as { ${field}: { $in: [...] } }, and a date bound as { ${field}: { $gte: new Date(...) } }.`,
|
|
1064
|
+
{ field }
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
const operatorEntries = Object.entries(value);
|
|
1068
|
+
if (operatorEntries.length === 0) refuseEmptyOperatorMap(field);
|
|
1069
|
+
for (const [operator, operatorValue] of operatorEntries) {
|
|
631
1070
|
if (operator === "$regex") {
|
|
632
1071
|
throw new FilterOperatorError(
|
|
633
|
-
`[ObjectUI] The '$regex' filter operator is not supported. It used to be converted to 'contains', which matches a literal substring rather than a pattern \u2014 a different result, not a degraded one. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. Use $contains, $
|
|
1072
|
+
`[ObjectUI] The '$regex' filter operator is not supported. It used to be converted to 'contains', which matches a literal substring rather than a pattern \u2014 a different result, not a degraded one. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. Use $contains for a case-sensitive substring, $icontains for a case-insensitive one, or $startsWith / $endsWith.`,
|
|
1073
|
+
{ operator: "$regex", field }
|
|
634
1074
|
);
|
|
635
1075
|
}
|
|
636
1076
|
if (operator === "$null") {
|
|
@@ -641,12 +1081,33 @@ function convertFiltersToAST(filter) {
|
|
|
641
1081
|
conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
|
|
642
1082
|
continue;
|
|
643
1083
|
}
|
|
1084
|
+
if (operator === "$empty") {
|
|
1085
|
+
if (operatorValue !== true && operatorValue !== false) {
|
|
1086
|
+
throw new FilterOperatorError(
|
|
1087
|
+
`[ObjectUI] The '$empty' filter operator on field '${field}' takes a boolean flag (true or false); received ${JSON.stringify(operatorValue) ?? String(operatorValue)}. Write { ${field}: { $empty: true } } for an empty value, or { ${field}: { $empty: false } } for a non-empty one.`,
|
|
1088
|
+
{ operator: "$empty", field }
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
conditions.push([field, operatorValue ? "is_empty" : "is_not_empty", true]);
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
644
1094
|
const astOperator = convertOperatorToAST(operator);
|
|
645
1095
|
if (astOperator) {
|
|
1096
|
+
if (astOperator === "icontains" && (0, import_data.isRefusedTextComparand)(operatorValue)) {
|
|
1097
|
+
refuseTextComparand(field, operator, operatorValue);
|
|
1098
|
+
}
|
|
646
1099
|
conditions.push([field, astOperator, operatorValue]);
|
|
647
1100
|
} else {
|
|
1101
|
+
const canonical = RETIRED_OPERATOR_ALIASES[operator];
|
|
1102
|
+
if (canonical) {
|
|
1103
|
+
throw new FilterOperatorError(
|
|
1104
|
+
`[ObjectUI] The '${operator}' filter operator is retired. Write '${canonical}' instead \u2014 the canonical spelling '@objectstack/spec' declares in FILTER_OPERATORS (data/filter.zod.ts). The operator itself is unchanged; only the key is renamed. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. It used to be accepted here as a lowercase alias while the in-memory matcher refused it, so the same filter selected rows through one data source and none through the other (objectui#8568).`,
|
|
1105
|
+
{ operator, field }
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
648
1108
|
throw new FilterOperatorError(
|
|
649
|
-
`[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, $notContains, $startsWith, $endsWith, $null, $exists. If you need exact object matching, use the value directly without an operator
|
|
1109
|
+
`[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, $notContains, $startsWith, $endsWith, $icontains, $null, $exists, $empty. If you need exact object matching, use the value directly without an operator.`,
|
|
1110
|
+
{ operator, field }
|
|
650
1111
|
);
|
|
651
1112
|
}
|
|
652
1113
|
}
|
|
@@ -655,6 +1116,13 @@ function convertFiltersToAST(filter) {
|
|
|
655
1116
|
}
|
|
656
1117
|
}
|
|
657
1118
|
if (conditions.length === 0) {
|
|
1119
|
+
const keysTheLoopProcessed = Object.keys(filter).length - skippedNullKeys;
|
|
1120
|
+
if (trueIdentityGroups > 0 && trueIdentityGroups === keysTheLoopProcessed) {
|
|
1121
|
+
return void 0;
|
|
1122
|
+
}
|
|
1123
|
+
if (skippedNullKeys > 0 && skippedNullKeys === Object.keys(filter).length) {
|
|
1124
|
+
return void 0;
|
|
1125
|
+
}
|
|
658
1126
|
return filter;
|
|
659
1127
|
}
|
|
660
1128
|
if (conditions.length === 1) {
|
|
@@ -662,6 +1130,49 @@ function convertFiltersToAST(filter) {
|
|
|
662
1130
|
}
|
|
663
1131
|
return ["and", ...conditions];
|
|
664
1132
|
}
|
|
1133
|
+
var ARRAY_VALUED_VIEW_OPERATORS = /* @__PURE__ */ new Set([
|
|
1134
|
+
...import_ui.VIEW_FILTER_LIST_VALUE_OPERATORS,
|
|
1135
|
+
...import_ui.VIEW_FILTER_PAIR_VALUE_OPERATORS
|
|
1136
|
+
]);
|
|
1137
|
+
var KNOWN_VIEW_OPERATORS = new Set(import_ui.VIEW_FILTER_OPERATORS);
|
|
1138
|
+
|
|
1139
|
+
// ../core/src/utils/column-sortability.ts
|
|
1140
|
+
var import_api2 = require("@objectstack/spec/api");
|
|
1141
|
+
var OBJECT_SORTABILITY_KEY = /* @__PURE__ */ Symbol.for("objectui.objectSortability");
|
|
1142
|
+
function asFieldSortability(value) {
|
|
1143
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
1144
|
+
const sortable = value.sortable;
|
|
1145
|
+
if (typeof sortable !== "boolean") return void 0;
|
|
1146
|
+
const reason = value.reason;
|
|
1147
|
+
const caveat = value.caveat;
|
|
1148
|
+
return {
|
|
1149
|
+
sortable,
|
|
1150
|
+
...typeof reason === "string" ? { reason } : {},
|
|
1151
|
+
...typeof caveat === "string" ? { caveat } : {}
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
function normalizeObjectSortability(served) {
|
|
1155
|
+
if (!served || typeof served !== "object" || Array.isArray(served)) return void 0;
|
|
1156
|
+
const rawFields = served.fields;
|
|
1157
|
+
if (!rawFields || typeof rawFields !== "object" || Array.isArray(rawFields)) return void 0;
|
|
1158
|
+
const fields = {};
|
|
1159
|
+
for (const [name, entry] of Object.entries(rawFields)) {
|
|
1160
|
+
const verdict = asFieldSortability(entry);
|
|
1161
|
+
if (verdict) fields[name] = verdict;
|
|
1162
|
+
}
|
|
1163
|
+
return { fields };
|
|
1164
|
+
}
|
|
1165
|
+
function attachObjectSortability(schema, served) {
|
|
1166
|
+
const projection = normalizeObjectSortability(served);
|
|
1167
|
+
if (!projection || !schema || typeof schema !== "object") return schema;
|
|
1168
|
+
Object.defineProperty(schema, OBJECT_SORTABILITY_KEY, {
|
|
1169
|
+
value: projection,
|
|
1170
|
+
enumerable: false,
|
|
1171
|
+
configurable: true,
|
|
1172
|
+
writable: true
|
|
1173
|
+
});
|
|
1174
|
+
return schema;
|
|
1175
|
+
}
|
|
665
1176
|
|
|
666
1177
|
// ../core/src/adapters/batchTransaction.ts
|
|
667
1178
|
function idOf(rec) {
|
|
@@ -730,20 +1241,143 @@ async function emulateBatchTransaction(dataSource, operations) {
|
|
|
730
1241
|
}
|
|
731
1242
|
|
|
732
1243
|
// ../core/src/utils/reference-keys.ts
|
|
733
|
-
|
|
1244
|
+
var import_data3 = require("@objectstack/spec/data");
|
|
1245
|
+
var aliasProbe = (key) => key.toLowerCase().replace(/[_\-\s]/g, "");
|
|
1246
|
+
var declaredFieldKeys = null;
|
|
1247
|
+
var probeFolds = null;
|
|
1248
|
+
var collidingProbes = null;
|
|
1249
|
+
function fieldKeyFolds() {
|
|
1250
|
+
if (probeFolds) return probeFolds;
|
|
1251
|
+
const declared = Object.keys(import_data3.FieldSchema.shape);
|
|
1252
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1253
|
+
for (const key of declared) {
|
|
1254
|
+
const probe = aliasProbe(key);
|
|
1255
|
+
seen.set(probe, [...seen.get(probe) ?? [], key]);
|
|
1256
|
+
}
|
|
1257
|
+
const folds = /* @__PURE__ */ new Map();
|
|
1258
|
+
const collisions = /* @__PURE__ */ new Map();
|
|
1259
|
+
for (const [probe, keys] of seen) {
|
|
1260
|
+
if (keys.length === 1) folds.set(probe, keys[0]);
|
|
1261
|
+
else collisions.set(probe, keys);
|
|
1262
|
+
}
|
|
1263
|
+
declaredFieldKeys = new Set(declared);
|
|
1264
|
+
collidingProbes = collisions;
|
|
1265
|
+
probeFolds = folds;
|
|
1266
|
+
return folds;
|
|
1267
|
+
}
|
|
1268
|
+
var warnedRetiredSpelling = /* @__PURE__ */ new Set();
|
|
1269
|
+
var warnedUnfoldableSpelling = /* @__PURE__ */ new Set();
|
|
1270
|
+
function warnUnfoldableRetiredKey(reason, key, detail, fieldName, objectName, f) {
|
|
1271
|
+
if (!isDev()) return;
|
|
1272
|
+
const named = fieldName ?? (typeof f.name === "string" ? f.name : "(unnamed field)");
|
|
1273
|
+
const owner = objectName ?? "(unknown object)";
|
|
1274
|
+
const memo = `${owner}:${named}:${key}:${reason}`;
|
|
1275
|
+
if (warnedUnfoldableSpelling.has(memo)) return;
|
|
1276
|
+
const published = reason === "no-declared-twin" && key === "id_field" ? ` \`@objectstack/spec\`'s \`FIELD_KEY_GUIDANCE\` gives the reason: ${import_data3.FIELD_KEY_GUIDANCE.id_field.why}` : "";
|
|
1277
|
+
warnedUnfoldableSpelling.add(memo);
|
|
1278
|
+
console.warn(
|
|
1279
|
+
`[ObjectUI] Object \`${owner}\`, field \`${named}\`: the ingestion choke point CANNOT canonicalize the retired spelling \`${key}\`. ${detail}${published} The key and its value are LEFT on the def exactly as served \u2014 nothing is dropped here \u2014 but a consumer that reads only the spellings \`@objectstack/spec\`'s \`FieldSchema\` declares will not see this value. Fix the PRODUCER, or migrate the stored document. (maintainer ruling item 3 on objectui#7650; objectui#8938)`
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
function canonicalizeRetiredFieldKeys(f, fieldName, objectName) {
|
|
1283
|
+
const folds = fieldKeyFolds();
|
|
1284
|
+
for (const key of Object.keys(f)) {
|
|
1285
|
+
if (declaredFieldKeys.has(key)) continue;
|
|
1286
|
+
const value = f[key];
|
|
1287
|
+
if (value === void 0) continue;
|
|
1288
|
+
if (REFERENCE_ARM_KEYS.has(key)) continue;
|
|
1289
|
+
const probe = aliasProbe(key);
|
|
1290
|
+
const canonical = folds.get(probe);
|
|
1291
|
+
if (canonical === void 0) {
|
|
1292
|
+
const colliding = collidingProbes.get(probe);
|
|
1293
|
+
if (colliding) {
|
|
1294
|
+
warnUnfoldableRetiredKey(
|
|
1295
|
+
"ambiguous-probe",
|
|
1296
|
+
key,
|
|
1297
|
+
`Its alias spelling is shared by MORE THAN ONE key \`FieldSchema\` declares (${colliding.map((c) => `\`${c}\``).join(", ")}), so the fold refuses to choose between them rather than picking one.`,
|
|
1298
|
+
fieldName,
|
|
1299
|
+
objectName,
|
|
1300
|
+
f
|
|
1301
|
+
);
|
|
1302
|
+
} else {
|
|
1303
|
+
warnUnfoldableRetiredKey(
|
|
1304
|
+
"no-declared-twin",
|
|
1305
|
+
key,
|
|
1306
|
+
`\`FieldSchema\` declares neither \`${key}\` nor any key sharing its alias spelling (lowercased, with \`_\`, \`-\` and spaces removed), so there is no canonical key to fold it onto \u2014 and this path deliberately does not guess a near match.`,
|
|
1307
|
+
fieldName,
|
|
1308
|
+
objectName,
|
|
1309
|
+
f
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
if (f[canonical] !== void 0) {
|
|
1315
|
+
if (f[canonical] !== value) {
|
|
1316
|
+
warnUnfoldableRetiredKey(
|
|
1317
|
+
"canonical-occupied",
|
|
1318
|
+
key,
|
|
1319
|
+
`\`FieldSchema\` declares \`${canonical}\`, and this def already carries a DIFFERENT value under it; the producer's value stands, because this choke point never overwrites one.`,
|
|
1320
|
+
fieldName,
|
|
1321
|
+
objectName,
|
|
1322
|
+
f
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1327
|
+
f[canonical] = value;
|
|
1328
|
+
if (!isDev()) continue;
|
|
1329
|
+
const named = fieldName ?? (typeof f.name === "string" ? f.name : "(unnamed field)");
|
|
1330
|
+
const owner = objectName ?? "(unknown object)";
|
|
1331
|
+
const memo = `${owner}:${named}:${key}:${canonical}`;
|
|
1332
|
+
if (warnedRetiredSpelling.has(memo)) continue;
|
|
1333
|
+
warnedRetiredSpelling.add(memo);
|
|
1334
|
+
console.warn(
|
|
1335
|
+
`[ObjectUI] Object \`${owner}\`, field \`${named}\` carries the retired spelling \`${key}\`. \`@objectstack/spec\`'s \`FieldSchema\` declares \`${canonical}\` and does not declare \`${key}\`, so a producer emitting it would be refused at the metadata write door. ObjectUI stamps \`${canonical}\` here so this stored def still renders, but the consumers no longer carry a \`${key}\` fallback of their own \u2014 fix the PRODUCER, or migrate the stored document. (objectui#7650)`
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
var warnedLegacyOnly = /* @__PURE__ */ new Set();
|
|
1340
|
+
var isDev = () => globalThis.process?.env?.NODE_ENV !== "production";
|
|
1341
|
+
var LEGACY_REFERENCE_KEYS = ["reference_to", "referenceTo"];
|
|
1342
|
+
var REFERENCE_ARM_KEYS = new Set(LEGACY_REFERENCE_KEYS);
|
|
1343
|
+
function warnOnLegacyOnlyReference(f, fieldName, objectName) {
|
|
1344
|
+
if (!isDev()) return;
|
|
1345
|
+
if (f.reference !== void 0) return;
|
|
1346
|
+
const spelled = LEGACY_REFERENCE_KEYS.filter((k) => f[k] !== void 0 && f[k] !== "");
|
|
1347
|
+
if (spelled.length === 0) return;
|
|
1348
|
+
const named = fieldName ?? (typeof f.name === "string" ? f.name : "(unnamed field)");
|
|
1349
|
+
const owner = objectName ?? "(unknown object)";
|
|
1350
|
+
for (const key of spelled) {
|
|
1351
|
+
const memo = `${owner}:${named}:${key}:${String(f[key])}`;
|
|
1352
|
+
if (warnedLegacyOnly.has(memo)) continue;
|
|
1353
|
+
warnedLegacyOnly.add(memo);
|
|
1354
|
+
console.warn(
|
|
1355
|
+
`[ObjectUI] Object \`${owner}\`, field \`${named}\` declares its relationship target as \`${key}: '${String(f[key])}'\` and does NOT carry \`reference\`. \`reference\` is the only spelling the protocol declares: \`@objectstack/spec\`'s \`FieldSchema\` refuses \`${key}\` by name with \`unrecognized_keys\` ("Did you mean \`${key}\` -> \`reference\`?"). ObjectUI stamps \`reference\` here so this def still renders, but the readers no longer carry a \`${key}\` fallback of their own \u2014 fix the PRODUCER to emit \`reference\`, or serve the def through a backend that normalizes it (objectstack#13847 does this on the serve path and in \`os migrate meta\`). Maintainer ruling 2026-08-31: protocol normalization belongs on the server, the front end just executes the protocol. (objectui#6837)`
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
function normalizeFieldReferenceKeys(fieldDef, fieldName, objectName) {
|
|
734
1360
|
if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
|
|
735
1361
|
const f = fieldDef;
|
|
736
|
-
|
|
737
|
-
if (
|
|
738
|
-
|
|
739
|
-
if (
|
|
1362
|
+
canonicalizeRetiredFieldKeys(f, fieldName, objectName);
|
|
1363
|
+
if (f.reference !== void 0) return fieldDef;
|
|
1364
|
+
const legacy = f.reference_to ?? f.referenceTo;
|
|
1365
|
+
if (legacy == null || legacy === "") return fieldDef;
|
|
1366
|
+
warnOnLegacyOnlyReference(f, fieldName, objectName);
|
|
1367
|
+
f.reference = legacy;
|
|
740
1368
|
return fieldDef;
|
|
741
1369
|
}
|
|
742
1370
|
function normalizeSchemaReferenceKeys(schema) {
|
|
743
1371
|
const fields = schema && typeof schema === "object" ? schema.fields : null;
|
|
744
1372
|
if (!fields || typeof fields !== "object") return schema;
|
|
745
|
-
const
|
|
746
|
-
|
|
1373
|
+
const objectName = typeof schema.name === "string" ? schema.name : void 0;
|
|
1374
|
+
if (Array.isArray(fields)) {
|
|
1375
|
+
for (const def of fields) normalizeFieldReferenceKeys(def, void 0, objectName);
|
|
1376
|
+
} else {
|
|
1377
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
1378
|
+
normalizeFieldReferenceKeys(def, name, objectName);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
747
1381
|
return schema;
|
|
748
1382
|
}
|
|
749
1383
|
|
|
@@ -1116,6 +1750,25 @@ function isErrorType(error, errorClass) {
|
|
|
1116
1750
|
return error instanceof errorClass;
|
|
1117
1751
|
}
|
|
1118
1752
|
|
|
1753
|
+
// src/error-envelope.ts
|
|
1754
|
+
function readErrorEnvelope(body) {
|
|
1755
|
+
if (!body || typeof body !== "object") return {};
|
|
1756
|
+
const env = body;
|
|
1757
|
+
const nested = env.error && typeof env.error === "object" ? env.error : void 0;
|
|
1758
|
+
const reading = {};
|
|
1759
|
+
const message = text(nested?.message) ?? text(env.message) ?? text(env.error);
|
|
1760
|
+
if (message !== void 0) reading.message = message;
|
|
1761
|
+
const code = present(env.code) ?? present(nested?.code);
|
|
1762
|
+
if (code !== void 0) reading.code = code;
|
|
1763
|
+
return reading;
|
|
1764
|
+
}
|
|
1765
|
+
function text(value) {
|
|
1766
|
+
return typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
1767
|
+
}
|
|
1768
|
+
function present(value) {
|
|
1769
|
+
return typeof value === "string" ? value : void 0;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1119
1772
|
// src/userState.ts
|
|
1120
1773
|
var DEFAULT_RESOURCE = "sys_user_preference";
|
|
1121
1774
|
function createObjectStackUserStateAdapter(options) {
|
|
@@ -1218,6 +1871,33 @@ function createObjectStackUserStateAdapter(options) {
|
|
|
1218
1871
|
};
|
|
1219
1872
|
}
|
|
1220
1873
|
|
|
1874
|
+
// src/draft-envelope.ts
|
|
1875
|
+
var import_kernel = require("@objectstack/spec/kernel");
|
|
1876
|
+
function extractDraftBody(resp) {
|
|
1877
|
+
if (!resp || typeof resp !== "object") return null;
|
|
1878
|
+
const env = resp;
|
|
1879
|
+
if (!("item" in env)) return null;
|
|
1880
|
+
const body = env.item;
|
|
1881
|
+
if (!body || typeof body !== "object") return null;
|
|
1882
|
+
if (Object.keys(body).length === 0) return null;
|
|
1883
|
+
return (0, import_kernel.stripReadDecorations)(body);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// src/metadata-error.ts
|
|
1887
|
+
function extractIssues(e) {
|
|
1888
|
+
const issues = e?.issues;
|
|
1889
|
+
return Array.isArray(issues) ? issues : [];
|
|
1890
|
+
}
|
|
1891
|
+
function formatMetadataIssue(i) {
|
|
1892
|
+
return `\u2022 ${i.path && i.path.length > 0 ? i.path : "(root)"} \u2014 ${i.message}`;
|
|
1893
|
+
}
|
|
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);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1221
1901
|
// src/index.ts
|
|
1222
1902
|
var FILTER_OPERATOR_ALIASES = {
|
|
1223
1903
|
equals: "=",
|
|
@@ -1275,14 +1955,21 @@ var FILTER_OPERATOR_ALIASES = {
|
|
|
1275
1955
|
before: "<",
|
|
1276
1956
|
after: ">"
|
|
1277
1957
|
};
|
|
1278
|
-
function
|
|
1958
|
+
function toAstFilterOperator(op) {
|
|
1279
1959
|
if (typeof op !== "string") return null;
|
|
1280
1960
|
const lower = op.toLowerCase();
|
|
1281
1961
|
return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
|
|
1282
1962
|
}
|
|
1963
|
+
function describeFilterEntry(entry) {
|
|
1964
|
+
try {
|
|
1965
|
+
return JSON.stringify(entry, (_key, value) => typeof value === "bigint" ? `${value}n` : value) ?? String(entry);
|
|
1966
|
+
} catch {
|
|
1967
|
+
return String(entry);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1283
1970
|
var MalformedFilterError = class extends Error {
|
|
1284
1971
|
constructor(entry, index) {
|
|
1285
|
-
const shown =
|
|
1972
|
+
const shown = describeFilterEntry(entry);
|
|
1286
1973
|
super(
|
|
1287
1974
|
`Filter entry ${index} is not a usable filter rule (${shown}). Expected { field, operator, value } with a non-empty field.`
|
|
1288
1975
|
);
|
|
@@ -1300,12 +1987,104 @@ function isMalformedFilterError(error) {
|
|
|
1300
1987
|
const e = error;
|
|
1301
1988
|
return e.code === "INVALID_FILTER" || e.name === "MalformedFilterError";
|
|
1302
1989
|
}
|
|
1303
|
-
|
|
1990
|
+
var UnloweredAggregateWhereError = class extends Error {
|
|
1991
|
+
constructor(where, resource) {
|
|
1992
|
+
const shown = JSON.stringify(where) ?? String(where);
|
|
1993
|
+
super(
|
|
1994
|
+
`aggregate('${resource}'): the spec-shape branch received a \`where\` array that is not a filter \u2014 ${shown}. This branch posts \`where\` to POST /data/:object/query verbatim, so it must ALREADY be lowered: either a FilterCondition object (\`QuerySchema.where\`, @objectstack/spec data/query.zod.ts), or a FilterArray the ingress can lower \u2014 a comparison tuple (['stage','=','won']), a logical node (['and',[..],[..]]), or an array of nodes (data/filter.zod.ts, gate \`isFilterAST\`). A ViewFilterRule[] ([{ field, operator, value }, ...]) is authoring sugar, not a filter: lower it in the producer that built these aggregate params, not here. Nothing was sent to the server, so no unfiltered numbers came back.`
|
|
1995
|
+
);
|
|
1996
|
+
__publicField(this, "code", "INVALID_FILTER");
|
|
1997
|
+
__publicField(this, "httpStatus", 400);
|
|
1998
|
+
/** The value as received, so a caller can log what its producer actually built. */
|
|
1999
|
+
__publicField(this, "where");
|
|
2000
|
+
/** The object `aggregate()` was called for. */
|
|
2001
|
+
__publicField(this, "resource");
|
|
2002
|
+
this.name = "UnloweredAggregateWhereError";
|
|
2003
|
+
this.where = where;
|
|
2004
|
+
this.resource = resource;
|
|
2005
|
+
}
|
|
2006
|
+
};
|
|
2007
|
+
function assertSpecShapeWhereIsFilterAst(where, resource) {
|
|
2008
|
+
if (!Array.isArray(where)) return;
|
|
2009
|
+
if (where.length === 0) return;
|
|
2010
|
+
if ((0, import_data5.isFilterAST)(where)) return;
|
|
2011
|
+
throw new UnloweredAggregateWhereError(where, resource);
|
|
2012
|
+
}
|
|
2013
|
+
var ANALYTICS_ONLY_AGGREGATE_KEYS = ["filter", "field", "function"];
|
|
2014
|
+
var ANALYTICS_ONLY_KEY_ADVICE = {
|
|
2015
|
+
filter: "`filter` is the ANALYTICS branch's filter and is lowered there through `parseFilterAST`; this branch's filter is `where` (`QuerySchema.where`, @objectstack/spec data/query.zod.ts) and is posted VERBATIM \u2014 rename it to `where` and lower it in the producer.",
|
|
2016
|
+
field: "`field` is half of the analytics branch's `${field}_${function}` measure; this branch's measure is `aggregations`, an AggregationNode[] such as [{ function: 'sum', field: 'amount', alias: 'amount_sum' }].",
|
|
2017
|
+
function: "`function` is the other half of that measure pair and belongs inside an `aggregations` node, not beside `groupBy`."
|
|
2018
|
+
};
|
|
2019
|
+
function specShapeSelectorReasons(params) {
|
|
2020
|
+
const reasons = [];
|
|
2021
|
+
if (Array.isArray(params?.groupBy)) reasons.push("`groupBy` is an array");
|
|
2022
|
+
if (Array.isArray(params?.aggregations)) reasons.push("`aggregations` is an array");
|
|
2023
|
+
if (params?.where !== void 0) reasons.push("a `where` key is present");
|
|
2024
|
+
return reasons;
|
|
2025
|
+
}
|
|
2026
|
+
var AnalyticsKeysOnSpecShapeError = class extends Error {
|
|
2027
|
+
constructor(keys, params, resource) {
|
|
2028
|
+
const received = {};
|
|
2029
|
+
for (const key of keys) received[key] = params?.[key];
|
|
2030
|
+
const named = keys.map((k) => `\`${k}\``).join(", ");
|
|
2031
|
+
const shown = keys.map((k) => `${k}=${JSON.stringify(params?.[k]) ?? String(params?.[k])}`).join(", ");
|
|
2032
|
+
const why = specShapeSelectorReasons(params).join(" and ");
|
|
2033
|
+
const noMeasure = !Array.isArray(params?.aggregations) ? " Because no `aggregations` was supplied either, the query this would have built is a grouping with NO MEASURE \u2014 the chart renders and its numbers mean nothing." : "";
|
|
2034
|
+
super(
|
|
2035
|
+
`aggregate('${resource}'): the spec-shape branch received the analytics branch's ${named} \u2014 ${shown} \u2014 and reads none of them. This call took the spec-shape branch because ${why}; the branch posts only \`groupBy\`, \`aggregations\`, \`where\` and \`limit\` to POST /data/:object/query, so those keys would have been dropped in silence.${noMeasure} ` + keys.map((k) => ANALYTICS_ONLY_KEY_ADVICE[k]).join(" ") + " The two shapes are alternatives, not a mixture: the analytics branch (`{ field, function, groupBy, filter }`, which lowers its filter for you) is reached only when NONE of an array `groupBy`, an array `aggregations` or a `where` key is present. Nothing was sent to the server, so no unfiltered numbers came back."
|
|
2036
|
+
);
|
|
2037
|
+
__publicField(this, "code", "INVALID_FILTER");
|
|
2038
|
+
__publicField(this, "httpStatus", 400);
|
|
2039
|
+
/** The offending keys, in the order {@link ANALYTICS_ONLY_AGGREGATE_KEYS} lists them. */
|
|
2040
|
+
__publicField(this, "keys");
|
|
2041
|
+
/** What each offending key carried, so a producer is identifiable from a log. */
|
|
2042
|
+
__publicField(this, "received");
|
|
2043
|
+
/** The object `aggregate()` was called for. */
|
|
2044
|
+
__publicField(this, "resource");
|
|
2045
|
+
this.name = "AnalyticsKeysOnSpecShapeError";
|
|
2046
|
+
this.keys = keys;
|
|
2047
|
+
this.received = received;
|
|
2048
|
+
this.resource = resource;
|
|
2049
|
+
}
|
|
2050
|
+
};
|
|
2051
|
+
function assertNoAnalyticsKeysOnSpecShape(params, resource) {
|
|
2052
|
+
const present2 = ANALYTICS_ONLY_AGGREGATE_KEYS.filter((key) => params?.[key] != null);
|
|
2053
|
+
if (present2.length === 0) return;
|
|
2054
|
+
throw new AnalyticsKeysOnSpecShapeError(present2, params, resource);
|
|
2055
|
+
}
|
|
2056
|
+
var UnlowerableAnalyticsFilterError = class extends Error {
|
|
2057
|
+
constructor(filter, resource, detail) {
|
|
2058
|
+
const shown = JSON.stringify(filter) ?? String(filter);
|
|
2059
|
+
super(
|
|
2060
|
+
`aggregate('${resource}'): the analytics branch received a \`filter\` array that \`parseFilterAST\` cannot lower into a FilterCondition \u2014 ${shown}.` + (detail ? ` (${detail})` : "") + " `where` on the analytics wire is a FilterCondition (`AnalyticsQuerySchema`, @objectstack/spec data/analytics.zod.ts), and a FilterArray is input-only sugar lowered into one at the single sink `parseFilterAST` (data/filter.zod.ts, objectstack#5158 ruling C). Lowerable shapes are a comparison tuple (['stage','=','won']), a logical node (['and',[..],[..]]), an array of such nodes, and a ViewFilterRule[] this adapter translates first. An infix join ([condA, 'or', condB]) is none of them. Nothing was sent to the server, so no unfiltered numbers came back."
|
|
2061
|
+
);
|
|
2062
|
+
__publicField(this, "code", "INVALID_FILTER");
|
|
2063
|
+
__publicField(this, "httpStatus", 400);
|
|
2064
|
+
/** The filter as received, so a caller can log what its producer actually built. */
|
|
2065
|
+
__publicField(this, "filter");
|
|
2066
|
+
/** The object `aggregate()` was called for. */
|
|
2067
|
+
__publicField(this, "resource");
|
|
2068
|
+
this.name = "UnlowerableAnalyticsFilterError";
|
|
2069
|
+
this.filter = filter;
|
|
2070
|
+
this.resource = resource;
|
|
2071
|
+
}
|
|
2072
|
+
};
|
|
2073
|
+
function refuseTextComparandEntry(entry, index, field, arrived) {
|
|
2074
|
+
const tail = `This is a { field, operator, value } rule entry, so it is refused as the filter is translated rather than sent: the spelling @objectstack/spec's FILTER_TEXT_CASES uses for this operator is the $-dialect '$icontains', which this form spells '${arrived}'. Remove the entry, or give it a non-empty string value (objectui#9048; the same refusal convertFiltersToAST and ValueDataSource already share).`;
|
|
2075
|
+
const error = new MalformedFilterError(entry, index);
|
|
2076
|
+
error.message = `Filter entry ${index} is refused. The ${(0, import_data6.textComparandRefusalReason)(field, arrived, entry.value)}. ${tail}`;
|
|
2077
|
+
throw error;
|
|
2078
|
+
}
|
|
2079
|
+
function objectFilterEntryToAST(entry, index) {
|
|
1304
2080
|
if (!entry || typeof entry !== "object") return null;
|
|
1305
2081
|
const field = entry.field;
|
|
1306
2082
|
const rawOp = entry.operator ?? entry.op ?? "=";
|
|
1307
|
-
const op =
|
|
2083
|
+
const op = toAstFilterOperator(rawOp);
|
|
1308
2084
|
if (!field || !op) return null;
|
|
2085
|
+
if (op === "icontains" && (0, import_data6.isRefusedTextComparand)(entry.value)) {
|
|
2086
|
+
refuseTextComparandEntry(entry, index, String(field), rawOp);
|
|
2087
|
+
}
|
|
1309
2088
|
return [String(field), op, entry.value];
|
|
1310
2089
|
}
|
|
1311
2090
|
function isObjectFilterEntryForm(filter) {
|
|
@@ -1314,8 +2093,8 @@ function isObjectFilterEntryForm(filter) {
|
|
|
1314
2093
|
}
|
|
1315
2094
|
function objectFilterEntriesToAST(entries) {
|
|
1316
2095
|
const nodes = entries.map((entry, i) => {
|
|
1317
|
-
if (Array.isArray(entry)) return translateFilterChild(entry);
|
|
1318
|
-
const tuple = objectFilterEntryToAST(entry);
|
|
2096
|
+
if (Array.isArray(entry)) return translateFilterChild(entry, i);
|
|
2097
|
+
const tuple = objectFilterEntryToAST(entry, i);
|
|
1319
2098
|
if (!tuple) throw new MalformedFilterError(entry, i);
|
|
1320
2099
|
return tuple;
|
|
1321
2100
|
});
|
|
@@ -1326,15 +2105,17 @@ function translateFilterArray(filter) {
|
|
|
1326
2105
|
if (isObjectFilterEntryForm(filter)) return objectFilterEntriesToAST(filter);
|
|
1327
2106
|
const head = filter[0];
|
|
1328
2107
|
if (typeof head === "string" && LOGICAL_AST_HEADS.has(head.toLowerCase())) {
|
|
1329
|
-
return [head, ...filter.slice(1).map(translateFilterChild)];
|
|
2108
|
+
return [head, ...filter.slice(1).map((child, i) => translateFilterChild(child, i + 1))];
|
|
2109
|
+
}
|
|
2110
|
+
if (filter.every((child) => Array.isArray(child))) {
|
|
2111
|
+
return filter.map((child, i) => translateFilterChild(child, i));
|
|
1330
2112
|
}
|
|
1331
|
-
if (filter.every((child) => Array.isArray(child))) return filter.map(translateFilterChild);
|
|
1332
2113
|
return filter;
|
|
1333
2114
|
}
|
|
1334
|
-
function translateFilterChild(child) {
|
|
2115
|
+
function translateFilterChild(child, index) {
|
|
1335
2116
|
if (Array.isArray(child)) return child.length > 0 ? translateFilterArray(child) : child;
|
|
1336
2117
|
if (child && typeof child === "object" && child.field !== void 0) {
|
|
1337
|
-
const tuple = objectFilterEntryToAST(child);
|
|
2118
|
+
const tuple = objectFilterEntryToAST(child, index);
|
|
1338
2119
|
if (tuple) return tuple;
|
|
1339
2120
|
}
|
|
1340
2121
|
return child;
|
|
@@ -1351,6 +2132,30 @@ function translateFilterToAST(filter) {
|
|
|
1351
2132
|
}
|
|
1352
2133
|
return void 0;
|
|
1353
2134
|
}
|
|
2135
|
+
function lowerAnalyticsFilterForWire(filter, resource) {
|
|
2136
|
+
if (!Array.isArray(filter)) return filter;
|
|
2137
|
+
if (filter.length === 0) return void 0;
|
|
2138
|
+
const ast = translateFilterArray(filter);
|
|
2139
|
+
if (!(0, import_data5.isFilterAST)(ast)) throw new UnlowerableAnalyticsFilterError(filter, resource);
|
|
2140
|
+
let lowered;
|
|
2141
|
+
try {
|
|
2142
|
+
lowered = (0, import_data5.parseFilterAST)(ast);
|
|
2143
|
+
} catch (e) {
|
|
2144
|
+
throw new UnlowerableAnalyticsFilterError(
|
|
2145
|
+
filter,
|
|
2146
|
+
resource,
|
|
2147
|
+
e instanceof Error ? e.message : String(e)
|
|
2148
|
+
);
|
|
2149
|
+
}
|
|
2150
|
+
if (lowered === void 0) {
|
|
2151
|
+
throw new UnlowerableAnalyticsFilterError(
|
|
2152
|
+
filter,
|
|
2153
|
+
resource,
|
|
2154
|
+
"`parseFilterAST` answered `undefined` for a non-empty filter"
|
|
2155
|
+
);
|
|
2156
|
+
}
|
|
2157
|
+
return lowered;
|
|
2158
|
+
}
|
|
1354
2159
|
function serializeOrderBy(orderby) {
|
|
1355
2160
|
if (orderby === void 0 || orderby === null) return void 0;
|
|
1356
2161
|
if (typeof orderby === "string") {
|
|
@@ -1413,6 +2218,37 @@ var APP_PERMISSION_DENIED_CODE = "PERMISSION_DENIED";
|
|
|
1413
2218
|
function isAppPermissionDeniedError(error) {
|
|
1414
2219
|
return errorCodeIs(error, APP_PERMISSION_DENIED_CODE);
|
|
1415
2220
|
}
|
|
2221
|
+
var META_ITEM_ABSENT_CODE = "RESOURCE_NOT_FOUND";
|
|
2222
|
+
function isMetaItemAbsentError(error) {
|
|
2223
|
+
return errorCodeIs(error, META_ITEM_ABSENT_CODE);
|
|
2224
|
+
}
|
|
2225
|
+
function metaEnvelopeCarriesItem(envelope) {
|
|
2226
|
+
if (envelope == null || typeof envelope !== "object") return false;
|
|
2227
|
+
return envelope.item != null;
|
|
2228
|
+
}
|
|
2229
|
+
var VIEW_CONFIG_CAPABILITY = "manage_metadata";
|
|
2230
|
+
var ViewConfigPermissionDeniedError = class extends Error {
|
|
2231
|
+
constructor(objectName, viewId) {
|
|
2232
|
+
super(
|
|
2233
|
+
`View configuration is shared: changing "${viewId}" changes it for everyone who uses this view. That requires the "${VIEW_CONFIG_CAPABILITY}" capability, which this session does not hold.`
|
|
2234
|
+
);
|
|
2235
|
+
__publicField(this, "code", "VIEW_CONFIG_PERMISSION_DENIED");
|
|
2236
|
+
/** The capability that was required and not held. */
|
|
2237
|
+
__publicField(this, "capability", VIEW_CONFIG_CAPABILITY);
|
|
2238
|
+
/** The object whose view configuration the refused write targeted. */
|
|
2239
|
+
__publicField(this, "objectName");
|
|
2240
|
+
/** The view id the refused write targeted. */
|
|
2241
|
+
__publicField(this, "viewId");
|
|
2242
|
+
this.name = "ViewConfigPermissionDeniedError";
|
|
2243
|
+
this.objectName = objectName;
|
|
2244
|
+
this.viewId = viewId;
|
|
2245
|
+
}
|
|
2246
|
+
};
|
|
2247
|
+
function isViewConfigPermissionDeniedError(error) {
|
|
2248
|
+
if (!error || typeof error !== "object") return false;
|
|
2249
|
+
const e = error;
|
|
2250
|
+
return e.code === "VIEW_CONFIG_PERMISSION_DENIED" || e.name === "ViewConfigPermissionDeniedError";
|
|
2251
|
+
}
|
|
1416
2252
|
var AnalyticsNotInstalledError = class extends Error {
|
|
1417
2253
|
constructor(surface, detail, serverCode) {
|
|
1418
2254
|
super(
|
|
@@ -1481,6 +2317,25 @@ var AnalyticsUnauthenticatedError = class extends Error {
|
|
|
1481
2317
|
this.serverMessage = opts.serverMessage;
|
|
1482
2318
|
}
|
|
1483
2319
|
};
|
|
2320
|
+
var AnalyticsForbiddenError = class extends Error {
|
|
2321
|
+
constructor(opts = {}) {
|
|
2322
|
+
super(
|
|
2323
|
+
`Analytics query refused: this user may not read the data behind ${opts.datasetName ? `dataset "${opts.datasetName}"` : "this dataset"}.` + (opts.serverMessage ? ` (server said: ${opts.serverMessage})` : "")
|
|
2324
|
+
);
|
|
2325
|
+
__publicField(this, "code", "PERMISSION_DENIED");
|
|
2326
|
+
__publicField(this, "httpStatus", 403);
|
|
2327
|
+
/** The server's own ADR-0112 code — the field this branch was chosen BY. */
|
|
2328
|
+
__publicField(this, "serverCode");
|
|
2329
|
+
/** The server's own message, verbatim (diagnostics only). */
|
|
2330
|
+
__publicField(this, "serverMessage");
|
|
2331
|
+
/** The dataset the refused query asked for, when it named one. */
|
|
2332
|
+
__publicField(this, "datasetName");
|
|
2333
|
+
this.name = "AnalyticsForbiddenError";
|
|
2334
|
+
this.serverCode = opts.serverCode;
|
|
2335
|
+
this.serverMessage = opts.serverMessage;
|
|
2336
|
+
this.datasetName = opts.datasetName;
|
|
2337
|
+
}
|
|
2338
|
+
};
|
|
1484
2339
|
function readAnalyticsErrorEnvelope(body) {
|
|
1485
2340
|
if (!body || typeof body !== "object") return {};
|
|
1486
2341
|
const flat = body;
|
|
@@ -1503,6 +2358,30 @@ var AnalyticsQueryRejectedError = class extends Error {
|
|
|
1503
2358
|
this.serverCode = serverCode;
|
|
1504
2359
|
}
|
|
1505
2360
|
};
|
|
2361
|
+
var AnalyticsResultShapeError = class extends Error {
|
|
2362
|
+
constructor(resource, result) {
|
|
2363
|
+
const value = result;
|
|
2364
|
+
const envelope = Array.isArray(value?.data?.rows);
|
|
2365
|
+
const received = result === null ? "null" : Array.isArray(result) ? "an array" : typeof result !== "object" ? typeof result : `an object with keys [${Object.keys(result).join(", ")}]`;
|
|
2366
|
+
super(
|
|
2367
|
+
envelope ? `aggregate('${resource}'): client.analytics.query resolved to the \`{ data: { rows } }\` envelope, not to an AnalyticsResult. @objectstack/client resolves this method to the payload itself since 17.3.0 (objectstack#13079), and this adapter reads \`rows\` only: an older client is installed beside it, or something between the client and POST /analytics/query wraps the payload a second time. Install @objectstack/client 17.3.0 or later.` : `aggregate('${resource}'): client.analytics.query resolved to ${received}, which has no \`rows\` array, so it is not an AnalyticsResult. This adapter reads \`rows\` only (objectui#7028).`
|
|
2368
|
+
);
|
|
2369
|
+
__publicField(this, "code", "ANALYTICS_RESULT_SHAPE_INVALID");
|
|
2370
|
+
/** The object `aggregate()` was called for. */
|
|
2371
|
+
__publicField(this, "resource");
|
|
2372
|
+
/**
|
|
2373
|
+
* True when the value is the envelope the client stopped handing back at
|
|
2374
|
+
* 17.3.0 — `{ success, data: { rows } }`, or its `{ data: { rows } }` core.
|
|
2375
|
+
*/
|
|
2376
|
+
__publicField(this, "envelope");
|
|
2377
|
+
/** What the value was, so the producer is identifiable from a log. */
|
|
2378
|
+
__publicField(this, "received");
|
|
2379
|
+
this.name = "AnalyticsResultShapeError";
|
|
2380
|
+
this.resource = resource;
|
|
2381
|
+
this.envelope = envelope;
|
|
2382
|
+
this.received = received;
|
|
2383
|
+
}
|
|
2384
|
+
};
|
|
1506
2385
|
function classifyAnalyticsFailure(error) {
|
|
1507
2386
|
const err = error ?? {};
|
|
1508
2387
|
const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
|
|
@@ -1514,11 +2393,11 @@ function classifyAnalyticsFailure(error) {
|
|
|
1514
2393
|
if (errorCodeIs({ code }, "VALIDATION_FAILED")) return { kind: "rejected", code, message };
|
|
1515
2394
|
if (errorCodeIs({ code }, "UNAUTHENTICATED")) return { kind: "unauthenticated", code, message };
|
|
1516
2395
|
if (errorCodeIs({ code }, "CUBE_NOT_FOUND")) return { kind: "cube-not-found", code, message };
|
|
2396
|
+
if (status === 400) return { kind: "rejected", code, message };
|
|
1517
2397
|
if (code === void 0) {
|
|
1518
2398
|
if (status !== void 0 && ANALYTICS_ABSENT_STATUSES.includes(status)) {
|
|
1519
2399
|
return { kind: "not-installed", code, message };
|
|
1520
2400
|
}
|
|
1521
|
-
if (status === 400) return { kind: "rejected", code, message };
|
|
1522
2401
|
if (status === 401) return { kind: "unauthenticated", code, message };
|
|
1523
2402
|
}
|
|
1524
2403
|
return { kind: "unknown", code, message };
|
|
@@ -1530,9 +2409,22 @@ var ConcurrentUpdateError = class extends Error {
|
|
|
1530
2409
|
__publicField(this, "httpStatus", 409);
|
|
1531
2410
|
__publicField(this, "currentVersion");
|
|
1532
2411
|
__publicField(this, "currentRecord");
|
|
2412
|
+
/**
|
|
2413
|
+
* The refusal text the PRODUCER marked as addressed to the end user
|
|
2414
|
+
* (`ApiErrorSchema.userMessage`, objectstack `79c46da90`), or `null` when the
|
|
2415
|
+
* refusal carried no marking.
|
|
2416
|
+
*
|
|
2417
|
+
* Declared on the class because this error has no `details` bag: the shared
|
|
2418
|
+
* reader (`declaredUserMessage` in `@object-ui/react`) looks at the
|
|
2419
|
+
* top-level field first, so parking it here is what makes the marking
|
|
2420
|
+
* readable at the surface. The contract is status-agnostic — a 409 carries
|
|
2421
|
+
* it exactly like a 400 or a 403 does.
|
|
2422
|
+
*/
|
|
2423
|
+
__publicField(this, "userMessage");
|
|
1533
2424
|
this.name = "ConcurrentUpdateError";
|
|
1534
2425
|
this.currentVersion = opts.currentVersion;
|
|
1535
2426
|
this.currentRecord = opts.currentRecord;
|
|
2427
|
+
this.userMessage = opts.userMessage ?? null;
|
|
1536
2428
|
}
|
|
1537
2429
|
};
|
|
1538
2430
|
function isConcurrentUpdateError(error) {
|
|
@@ -1540,10 +2432,18 @@ function isConcurrentUpdateError(error) {
|
|
|
1540
2432
|
const e = error;
|
|
1541
2433
|
return e.code === "CONCURRENT_UPDATE" || e.name === "ConcurrentUpdateError";
|
|
1542
2434
|
}
|
|
2435
|
+
function liftUserMessage(e, details) {
|
|
2436
|
+
if (typeof e.userMessage === "string" && e.userMessage.trim()) return e.userMessage;
|
|
2437
|
+
if (typeof details.userMessage === "string" && details.userMessage.trim()) {
|
|
2438
|
+
return details.userMessage;
|
|
2439
|
+
}
|
|
2440
|
+
return null;
|
|
2441
|
+
}
|
|
1543
2442
|
function normaliseClientError(error) {
|
|
1544
2443
|
if (!error || typeof error !== "object") return error;
|
|
1545
2444
|
const e = error;
|
|
1546
2445
|
const details = e.details ?? {};
|
|
2446
|
+
const userMessage = liftUserMessage(e, details);
|
|
1547
2447
|
if (e.code === "VALIDATION_FAILED" || e.name === "ValidationError") {
|
|
1548
2448
|
const rawFields = Array.isArray(details.fields) ? details.fields : Array.isArray(e.fields) ? e.fields : [];
|
|
1549
2449
|
const validationErrors = rawFields.map((f) => {
|
|
@@ -1557,15 +2457,21 @@ function normaliseClientError(error) {
|
|
|
1557
2457
|
typeof e.message === "string" ? e.message : "Validation failed",
|
|
1558
2458
|
validationErrors[0]?.field,
|
|
1559
2459
|
validationErrors,
|
|
1560
|
-
|
|
2460
|
+
// The marking rides the details bag exactly the way `fields` already
|
|
2461
|
+
// does, landing on `err.details.userMessage` — the second of the two
|
|
2462
|
+
// places the shared reader looks. Purely additive: `field`,
|
|
2463
|
+
// `validationErrors` and `fields` are untouched, so the per-field
|
|
2464
|
+
// marking a form already draws keeps working. Omitted entirely when
|
|
2465
|
+
// unmarked, so an unmarked refusal carries no empty key.
|
|
2466
|
+
userMessage === null ? { fields: rawFields } : { fields: rawFields, userMessage }
|
|
1561
2467
|
);
|
|
1562
2468
|
}
|
|
1563
|
-
if (e.code !== "CONCURRENT_UPDATE" && e.httpStatus !== 409) return error;
|
|
1564
2469
|
if (e.code !== "CONCURRENT_UPDATE") return error;
|
|
1565
2470
|
return new ConcurrentUpdateError({
|
|
1566
2471
|
currentVersion: typeof details.currentVersion === "string" ? details.currentVersion : null,
|
|
1567
2472
|
currentRecord: details.currentRecord ?? null,
|
|
1568
|
-
message: typeof e.message === "string" ? e.message : void 0
|
|
2473
|
+
message: typeof e.message === "string" ? e.message : void 0,
|
|
2474
|
+
userMessage
|
|
1569
2475
|
});
|
|
1570
2476
|
}
|
|
1571
2477
|
function formatHttpFailureMessage(message, meta) {
|
|
@@ -1620,6 +2526,80 @@ function createQuietHttpLogger() {
|
|
|
1620
2526
|
};
|
|
1621
2527
|
return logger;
|
|
1622
2528
|
}
|
|
2529
|
+
var RECOGNIZED_DROP_REASONS = new Set(
|
|
2530
|
+
import_data4.DroppedFieldsEventSchema.shape.reason.options
|
|
2531
|
+
);
|
|
2532
|
+
var UNRECOGNIZED_DROP_REASON = "objectui:unrecognized-drop-reason";
|
|
2533
|
+
function isRecognizedDropReason(reason) {
|
|
2534
|
+
return RECOGNIZED_DROP_REASONS.has(reason);
|
|
2535
|
+
}
|
|
2536
|
+
function isWireDroppedFieldsEntry(e) {
|
|
2537
|
+
return !!e && typeof e === "object" && Array.isArray(e.fields) && e.fields.some((f) => typeof f === "string");
|
|
2538
|
+
}
|
|
2539
|
+
function asDroppedFieldsNotice(entry, fallbackObject) {
|
|
2540
|
+
const fields = entry.fields.filter((f) => typeof f === "string");
|
|
2541
|
+
const object = typeof entry.object === "string" ? entry.object : fallbackObject;
|
|
2542
|
+
if (isRecognizedDropReason(entry.reason)) {
|
|
2543
|
+
return { ...entry, object, fields, reason: entry.reason };
|
|
2544
|
+
}
|
|
2545
|
+
return {
|
|
2546
|
+
...entry,
|
|
2547
|
+
object,
|
|
2548
|
+
fields,
|
|
2549
|
+
reason: UNRECOGNIZED_DROP_REASON,
|
|
2550
|
+
unrecognizedReason: entry.reason
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
var UNATTRIBUTED_STRIP_OBJECT = "";
|
|
2554
|
+
var META_ROUTE_ABSENT_CODES = ["ROUTE_NOT_FOUND", "NOT_IMPLEMENTED"];
|
|
2555
|
+
var IMPORT_MAPPINGS_UNKNOWN_KIND_CODE = "INVALID_REQUEST";
|
|
2556
|
+
var IMPORT_MAPPINGS_UNKNOWN_KIND_STATUS = 400;
|
|
2557
|
+
var META_ABSENT_STATUSES = [404, 501];
|
|
2558
|
+
var META_REFUSAL_CODES = ["UNAUTHENTICATED", "PERMISSION_DENIED"];
|
|
2559
|
+
var META_REFUSAL_STATUSES = [401, 403, 405];
|
|
2560
|
+
function classifyImportMappingsFailure(error) {
|
|
2561
|
+
const err = error ?? {};
|
|
2562
|
+
const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
|
|
2563
|
+
const message = typeof err.message === "string" ? err.message : void 0;
|
|
2564
|
+
const status = typeof err.httpStatus === "number" ? err.httpStatus : typeof err.status === "number" ? err.status : typeof err.statusCode === "number" ? err.statusCode : void 0;
|
|
2565
|
+
const found = { code, status, message };
|
|
2566
|
+
if (errorCodeIsAnyOf({ code }, META_ROUTE_ABSENT_CODES)) {
|
|
2567
|
+
return { kind: "not-served", ...found };
|
|
2568
|
+
}
|
|
2569
|
+
if (errorCodeIs({ code }, IMPORT_MAPPINGS_UNKNOWN_KIND_CODE) && status === IMPORT_MAPPINGS_UNKNOWN_KIND_STATUS) {
|
|
2570
|
+
return { kind: "not-served", ...found };
|
|
2571
|
+
}
|
|
2572
|
+
if (errorCodeIsAnyOf({ code }, META_REFUSAL_CODES)) {
|
|
2573
|
+
return { kind: "refused", ...found };
|
|
2574
|
+
}
|
|
2575
|
+
if (status !== void 0 && META_REFUSAL_STATUSES.includes(status)) {
|
|
2576
|
+
return { kind: "refused", ...found };
|
|
2577
|
+
}
|
|
2578
|
+
if (code === void 0 && status !== void 0 && META_ABSENT_STATUSES.includes(status)) {
|
|
2579
|
+
return { kind: "not-served", ...found };
|
|
2580
|
+
}
|
|
2581
|
+
return { kind: "unreadable", ...found };
|
|
2582
|
+
}
|
|
2583
|
+
function classifyViewsFailure(error) {
|
|
2584
|
+
const err = error ?? {};
|
|
2585
|
+
const code = typeof err.code === "string" && err.code.length > 0 ? err.code : void 0;
|
|
2586
|
+
const message = typeof err.message === "string" ? err.message : void 0;
|
|
2587
|
+
const status = typeof err.httpStatus === "number" ? err.httpStatus : typeof err.status === "number" ? err.status : typeof err.statusCode === "number" ? err.statusCode : void 0;
|
|
2588
|
+
const found = { code, status, message };
|
|
2589
|
+
if (errorCodeIsAnyOf({ code }, META_ROUTE_ABSENT_CODES)) {
|
|
2590
|
+
return { kind: "not-served", ...found };
|
|
2591
|
+
}
|
|
2592
|
+
if (errorCodeIsAnyOf({ code }, META_REFUSAL_CODES)) {
|
|
2593
|
+
return { kind: "refused", ...found };
|
|
2594
|
+
}
|
|
2595
|
+
if (status !== void 0 && META_REFUSAL_STATUSES.includes(status)) {
|
|
2596
|
+
return { kind: "refused", ...found };
|
|
2597
|
+
}
|
|
2598
|
+
if (code === void 0 && status !== void 0 && META_ABSENT_STATUSES.includes(status)) {
|
|
2599
|
+
return { kind: "not-served", ...found };
|
|
2600
|
+
}
|
|
2601
|
+
return { kind: "unreadable", ...found };
|
|
2602
|
+
}
|
|
1623
2603
|
function stableStringify(value) {
|
|
1624
2604
|
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
1625
2605
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
@@ -1652,20 +2632,14 @@ function withoutNoOpDrops(droppedFields, sent, stored) {
|
|
|
1652
2632
|
}
|
|
1653
2633
|
return out;
|
|
1654
2634
|
}
|
|
2635
|
+
var VIEW_ITEM_ROUND_TRIP_KEYS = Object.keys(import_ui2.VIEW_CONSOLE_ROUND_TRIP_KEYS).filter((key) => import_ui2.VIEW_CONSOLE_ROUND_TRIP_KEYS[key].includes("viewItem"));
|
|
1655
2636
|
function viewItemObjectName(item) {
|
|
1656
2637
|
const spec = item?.list ?? item;
|
|
1657
|
-
return spec?.data?.object ?? spec?.object
|
|
2638
|
+
return spec?.data?.object ?? spec?.object;
|
|
1658
2639
|
}
|
|
1659
2640
|
var VIEW_OVERLAY_MARKER = "_isOverride";
|
|
1660
|
-
function isLegacyOverlayRow(item, spec) {
|
|
1661
|
-
if (spec && spec.config && typeof spec.config === "object") return false;
|
|
1662
|
-
const viewKind = item?.viewKind ?? spec?.viewKind;
|
|
1663
|
-
return viewKind === "list";
|
|
1664
|
-
}
|
|
1665
2641
|
function isPersonalizationOverlayRow(item, spec) {
|
|
1666
|
-
|
|
1667
|
-
if (spec?.[VIEW_OVERLAY_MARKER] === true) return true;
|
|
1668
|
-
return isLegacyOverlayRow(item, spec);
|
|
2642
|
+
return item?.[VIEW_OVERLAY_MARKER] === true || spec?.[VIEW_OVERLAY_MARKER] === true;
|
|
1669
2643
|
}
|
|
1670
2644
|
var VIEW_OVERLAY_OWNED_KEYS = Object.freeze([
|
|
1671
2645
|
"rowHeight",
|
|
@@ -1697,8 +2671,9 @@ function narrowPersonalizationOverlay(row) {
|
|
|
1697
2671
|
function unwrapViewDraft(resp) {
|
|
1698
2672
|
if (!resp || typeof resp !== "object") return null;
|
|
1699
2673
|
const env = resp;
|
|
1700
|
-
const
|
|
1701
|
-
if (!
|
|
2674
|
+
const raw = "item" in env ? env.item : env;
|
|
2675
|
+
if (!raw || typeof raw !== "object") return null;
|
|
2676
|
+
const body = (0, import_kernel2.stripReadDecorations)(raw);
|
|
1702
2677
|
const spec = body.list ?? body;
|
|
1703
2678
|
if (!spec || typeof spec !== "object") return null;
|
|
1704
2679
|
return Object.keys(spec).length > 0 ? spec : null;
|
|
@@ -1754,10 +2729,10 @@ var ObjectStackAdapter = class {
|
|
|
1754
2729
|
// one adapter that has to cope with a backend lacking server atomicity (#2679).
|
|
1755
2730
|
__publicField(this, "batchUnsupported", false);
|
|
1756
2731
|
// The server's declared cross-object atomic-batch capability, read from
|
|
1757
|
-
// discovery at connect() (
|
|
2732
|
+
// discovery at connect() (objectstack-ai/objectstack#3298 / objectui #2693). `true` → the
|
|
1758
2733
|
// backend GUARANTEES an atomic `/batch`, so batchTransaction trusts it and
|
|
1759
2734
|
// never degrades to the non-atomic emulation (any failure surfaces as a real
|
|
1760
|
-
// error). `false` or `undefined` (capability absent → backend predates #3298)
|
|
2735
|
+
// error). `false` or `undefined` (capability absent → backend predates objectstack-ai/objectstack#3298)
|
|
1761
2736
|
// → keep the legacy runtime-probe + emulation fallback so a save is still
|
|
1762
2737
|
// possible; dropping it there would turn "saves, less safe" into "no save
|
|
1763
2738
|
// path" on older backends (#2679 compatibility constraint).
|
|
@@ -1768,7 +2743,7 @@ var ObjectStackAdapter = class {
|
|
|
1768
2743
|
// inline-edit "Save All" writes without a manual reload.
|
|
1769
2744
|
__publicField(this, "mutationListeners", /* @__PURE__ */ new Set());
|
|
1770
2745
|
// Subscribers registered via onWriteWarning(). Emitted after a create/update
|
|
1771
|
-
// whose response carried `droppedFields` (
|
|
2746
|
+
// whose response carried `droppedFields` (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455) so the app
|
|
1772
2747
|
// shell can surface a toast instead of the strip passing silently.
|
|
1773
2748
|
__publicField(this, "writeWarningListeners", /* @__PURE__ */ new Set());
|
|
1774
2749
|
// Subscribers registered via onSaveAdvisory(). Emitted after a metadata save
|
|
@@ -1777,6 +2752,18 @@ var ObjectStackAdapter = class {
|
|
|
1777
2752
|
// above in every respect except which door produced the event: that one is
|
|
1778
2753
|
// record CRUD, this one is the metadata save door.
|
|
1779
2754
|
__publicField(this, "saveAdvisoryListeners", /* @__PURE__ */ new Set());
|
|
2755
|
+
// Subscribers registered via onMetadataReadWarning(). Emitted when a metadata
|
|
2756
|
+
// READ failed in a way that is NOT the supported "this deployment does not
|
|
2757
|
+
// serve that kind" shape, and was degraded to an empty result anyway
|
|
2758
|
+
// (objectui#7741). Third sibling of the two sets above: same seam shape, and
|
|
2759
|
+
// the opposite direction — those two describe a write that succeeded, this
|
|
2760
|
+
// one a read that did not happen.
|
|
2761
|
+
__publicField(this, "metadataReadWarningListeners", /* @__PURE__ */ new Set());
|
|
2762
|
+
// [ADR-0066] The session's REPORTED system capabilities, pushed in by the
|
|
2763
|
+
// host (see `setSystemCapabilities`). `undefined` means NO answer was ever
|
|
2764
|
+
// reported — which is NOT the same as a reported-empty grant, and the two
|
|
2765
|
+
// are treated differently by `maySetViewConfig` below.
|
|
2766
|
+
__publicField(this, "systemCapabilities");
|
|
1780
2767
|
this.client = new import_client.ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
|
|
1781
2768
|
this.installSaveAdvisoryInterceptor();
|
|
1782
2769
|
this.metadataCache = new MetadataCache(config.cache);
|
|
@@ -1785,6 +2772,7 @@ var ObjectStackAdapter = class {
|
|
|
1785
2772
|
this.reconnectDelay = config.reconnectDelay ?? 1e3;
|
|
1786
2773
|
this.baseUrl = config.baseUrl;
|
|
1787
2774
|
this.token = config.token;
|
|
2775
|
+
this.systemCapabilities = config.systemCapabilities;
|
|
1788
2776
|
this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
|
|
1789
2777
|
}
|
|
1790
2778
|
/**
|
|
@@ -1958,7 +2946,7 @@ var ObjectStackAdapter = class {
|
|
|
1958
2946
|
* — a per-object metadata-driven search (ADR-0061) — this consults the search
|
|
1959
2947
|
* index and ranks hits across objects, so it surfaces records the per-object
|
|
1960
2948
|
* fanout misses. Global affordances (⌘K command palette, search page) prefer
|
|
1961
|
-
* this path (
|
|
2949
|
+
* this path (objectstack-ai/objectstack#3371).
|
|
1962
2950
|
*
|
|
1963
2951
|
* Returns `{ query, hits }`. A backend without the search plugin installed
|
|
1964
2952
|
* answers `404`; we treat that as "no global search here" and return an empty
|
|
@@ -1991,10 +2979,10 @@ var ObjectStackAdapter = class {
|
|
|
1991
2979
|
if (!res.ok) {
|
|
1992
2980
|
if (res.status === 404) return { query: trimmed, hits: [] };
|
|
1993
2981
|
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
|
|
1994
|
-
const
|
|
1995
|
-
|
|
1996
|
-
);
|
|
2982
|
+
const envelope = readErrorEnvelope(errorBody);
|
|
2983
|
+
const err = new Error(envelope.message ?? res.statusText);
|
|
1997
2984
|
err.status = res.status;
|
|
2985
|
+
err.code = envelope.code;
|
|
1998
2986
|
throw err;
|
|
1999
2987
|
}
|
|
2000
2988
|
const body = await res.json();
|
|
@@ -2097,24 +3085,28 @@ var ObjectStackAdapter = class {
|
|
|
2097
3085
|
}
|
|
2098
3086
|
}
|
|
2099
3087
|
/**
|
|
2100
|
-
* Read `droppedFields` off a create/update response (
|
|
3088
|
+
* Read `droppedFields` off a create/update response (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455)
|
|
2101
3089
|
* and, when present, notify write-warning subscribers. Tolerant of a client
|
|
2102
3090
|
* whose response type predates `droppedFields`: the field is read structurally
|
|
2103
3091
|
* and validated, so an older client (or a backend that never drops) is a no-op.
|
|
3092
|
+
*
|
|
3093
|
+
* SHAPE decides whether an entry is an event at all (it must name at least one
|
|
3094
|
+
* field); `object`, `fields` and `reason` are then PARSED — an unrecognized
|
|
3095
|
+
* reason routed to the skew arm, a non-string field element refused, a missing
|
|
3096
|
+
* `object` healed from `resource` — never asserted into the union, and the
|
|
3097
|
+
* entry itself never dropped for them (objectui#4934, `f75810e7c`).
|
|
2104
3098
|
*/
|
|
2105
3099
|
notifyDroppedFields(operation, resource, result, id, sent) {
|
|
2106
3100
|
const dropped = result?.droppedFields;
|
|
2107
3101
|
if (!Array.isArray(dropped) || dropped.length === 0) return;
|
|
2108
|
-
const valid = dropped.filter(
|
|
2109
|
-
(e) => !!e && typeof e === "object" && Array.isArray(e.fields) && e.fields.length > 0
|
|
2110
|
-
);
|
|
3102
|
+
const valid = dropped.filter(isWireDroppedFieldsEntry).map((e) => asDroppedFieldsNotice(e, resource));
|
|
2111
3103
|
const stored = result?.record;
|
|
2112
3104
|
const droppedFields = withoutNoOpDrops(valid, sent, stored);
|
|
2113
3105
|
if (droppedFields.length === 0) return;
|
|
2114
3106
|
this.emitWriteWarning({ operation, resource, ...id !== void 0 ? { id } : {}, droppedFields });
|
|
2115
3107
|
}
|
|
2116
3108
|
/**
|
|
2117
|
-
* Same, for the cross-object transactional batch (
|
|
3109
|
+
* Same, for the cross-object transactional batch (objectstack-ai/objectstack#3794). Its
|
|
2118
3110
|
* response hangs the events off a top-level `droppedFields` list, each tagged
|
|
2119
3111
|
* with the `index` of the operation it came from — `results` entries are bare
|
|
2120
3112
|
* record echoes with nowhere to hang a per-row list.
|
|
@@ -2130,13 +3122,13 @@ var ObjectStackAdapter = class {
|
|
|
2130
3122
|
if (!Array.isArray(dropped) || dropped.length === 0) return;
|
|
2131
3123
|
const results = payload?.results;
|
|
2132
3124
|
for (const entry of dropped) {
|
|
2133
|
-
if (!entry
|
|
3125
|
+
if (!isWireDroppedFieldsEntry(entry)) continue;
|
|
2134
3126
|
const e = entry;
|
|
2135
|
-
if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
|
|
2136
3127
|
const op = typeof e.index === "number" ? operations[e.index] : void 0;
|
|
3128
|
+
const object = typeof e.object === "string" ? e.object : typeof op?.object === "string" ? op.object : UNATTRIBUTED_STRIP_OBJECT;
|
|
2137
3129
|
const stored = typeof e.index === "number" && Array.isArray(results) ? results[e.index] : void 0;
|
|
2138
3130
|
const [live] = withoutNoOpDrops(
|
|
2139
|
-
[{
|
|
3131
|
+
[asDroppedFieldsNotice({ fields: e.fields, reason: e.reason }, object)],
|
|
2140
3132
|
op?.data,
|
|
2141
3133
|
stored
|
|
2142
3134
|
);
|
|
@@ -2144,7 +3136,7 @@ var ObjectStackAdapter = class {
|
|
|
2144
3136
|
const operation = (op?.action ?? "create") === "create" ? "create" : "update";
|
|
2145
3137
|
this.emitWriteWarning({
|
|
2146
3138
|
operation,
|
|
2147
|
-
resource:
|
|
3139
|
+
resource: object,
|
|
2148
3140
|
...op?.id !== void 0 && op?.id !== null ? { id: op.id } : {},
|
|
2149
3141
|
droppedFields: [live]
|
|
2150
3142
|
});
|
|
@@ -2152,7 +3144,7 @@ var ObjectStackAdapter = class {
|
|
|
2152
3144
|
}
|
|
2153
3145
|
/**
|
|
2154
3146
|
* Subscribe to write-warning events (a create/update dropped caller-supplied
|
|
2155
|
-
* fields — #3431
|
|
3147
|
+
* fields — objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455). Returns an unsubscribe function. The app shell uses
|
|
2156
3148
|
* this to toast the user; the write itself already succeeded.
|
|
2157
3149
|
*/
|
|
2158
3150
|
onWriteWarning(callback) {
|
|
@@ -2166,7 +3158,7 @@ var ObjectStackAdapter = class {
|
|
|
2166
3158
|
* advisory findings on a save that SUCCEEDED (#4237; backend
|
|
2167
3159
|
* objectstack#7435). Returns an unsubscribe function.
|
|
2168
3160
|
*
|
|
2169
|
-
* Deliberately the same seam as {@link onWriteWarning} (#3431
|
|
3161
|
+
* Deliberately the same seam as {@link onWriteWarning} (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455), which
|
|
2170
3162
|
* is what {@link MetadataSaveAdvisoryEvent}'s own declaration already said it
|
|
2171
3163
|
* was modelled on. It is a SIBLING of that channel rather than a second
|
|
2172
3164
|
* payload pushed down it: `WriteWarningEvent` is a closed shape whose
|
|
@@ -2203,6 +3195,45 @@ var ObjectStackAdapter = class {
|
|
|
2203
3195
|
}
|
|
2204
3196
|
}
|
|
2205
3197
|
}
|
|
3198
|
+
/**
|
|
3199
|
+
* Subscribe to metadata read-warning events — a metadata READ that could not
|
|
3200
|
+
* be answered and was degraded to an empty result anyway (objectui#7741).
|
|
3201
|
+
* Returns an unsubscribe function. The app shell uses this to toast the user;
|
|
3202
|
+
* the caller has already been handed the empty result.
|
|
3203
|
+
*
|
|
3204
|
+
* Deliberately the same seam as {@link onWriteWarning} and
|
|
3205
|
+
* {@link onSaveAdvisory}, and a SIBLING of them rather than a payload pushed
|
|
3206
|
+
* down either: both of those announce a write that SUCCEEDED, and an event
|
|
3207
|
+
* saying "this read did not happen" carried on one of them would make it lie
|
|
3208
|
+
* about what occurred. What is reused is the seam's SHAPE — one long-lived
|
|
3209
|
+
* instance with a `subscribe -> unsubscribe` registration that
|
|
3210
|
+
* `AdapterProvider` wires once — not its event type.
|
|
3211
|
+
*
|
|
3212
|
+
* ⛔ Nothing here changes what a caller RECEIVES. `listImportMappings` still
|
|
3213
|
+
* answers `[]` on every failure, including the loud ones; this channel is
|
|
3214
|
+
* added ALONGSIDE that return, which is why it is not a change to a surface
|
|
3215
|
+
* published since `@object-ui/data-objectstack@17.1.0`.
|
|
3216
|
+
*/
|
|
3217
|
+
onMetadataReadWarning(callback) {
|
|
3218
|
+
this.metadataReadWarningListeners.add(callback);
|
|
3219
|
+
return () => {
|
|
3220
|
+
this.metadataReadWarningListeners.delete(callback);
|
|
3221
|
+
};
|
|
3222
|
+
}
|
|
3223
|
+
/**
|
|
3224
|
+
* Notify all metadata read-warning subscribers. Isolated exactly like
|
|
3225
|
+
* {@link emitWriteWarning}: a throwing listener must neither break the caller
|
|
3226
|
+
* nor starve the others.
|
|
3227
|
+
*/
|
|
3228
|
+
emitMetadataReadWarning(event) {
|
|
3229
|
+
for (const listener of this.metadataReadWarningListeners) {
|
|
3230
|
+
try {
|
|
3231
|
+
listener(event);
|
|
3232
|
+
} catch (err) {
|
|
3233
|
+
console.warn("ObjectStackAdapter: metadata read-warning listener error", err);
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
2206
3237
|
/**
|
|
2207
3238
|
* Install the ONE emitter for the metadata save door (#4237).
|
|
2208
3239
|
*
|
|
@@ -2260,6 +3291,12 @@ var ObjectStackAdapter = class {
|
|
|
2260
3291
|
this.emitSaveAdvisory({
|
|
2261
3292
|
type,
|
|
2262
3293
|
name,
|
|
3294
|
+
// #5026 — this interceptor wraps `meta.saveItem`, the SAVE door
|
|
3295
|
+
// (`PUT /meta/:type/:name`) and only that one. The SDK's publish
|
|
3296
|
+
// door (`meta.publishItem`) has no caller in this repo, so wiring
|
|
3297
|
+
// it here would be a surface with no consumer; `MetadataClient` is
|
|
3298
|
+
// where the publish door is actually taken.
|
|
3299
|
+
door: "save",
|
|
2263
3300
|
mode: result?.state === "draft" ? "draft" : "publish",
|
|
2264
3301
|
advisories
|
|
2265
3302
|
});
|
|
@@ -2307,7 +3344,7 @@ var ObjectStackAdapter = class {
|
|
|
2307
3344
|
data,
|
|
2308
3345
|
opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
|
|
2309
3346
|
);
|
|
2310
|
-
this.emitMutation({ type: "update", resource, id, record: { ...result.record } });
|
|
3347
|
+
this.emitMutation({ type: "update", resource, id: String(id), record: { ...result.record } });
|
|
2311
3348
|
this.notifyDroppedFields("update", resource, result, id, data);
|
|
2312
3349
|
return result.record;
|
|
2313
3350
|
} catch (err) {
|
|
@@ -2330,7 +3367,7 @@ var ObjectStackAdapter = class {
|
|
|
2330
3367
|
opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
|
|
2331
3368
|
);
|
|
2332
3369
|
if (result.success) {
|
|
2333
|
-
this.emitMutation({ type: "delete", resource, id });
|
|
3370
|
+
this.emitMutation({ type: "delete", resource, id: String(id) });
|
|
2334
3371
|
}
|
|
2335
3372
|
return result.success;
|
|
2336
3373
|
} catch (err) {
|
|
@@ -2431,25 +3468,25 @@ var ObjectStackAdapter = class {
|
|
|
2431
3468
|
* @returns Promise resolving to array of results
|
|
2432
3469
|
*/
|
|
2433
3470
|
/**
|
|
2434
|
-
* Cross-object transactional batch (
|
|
3471
|
+
* Cross-object transactional batch (objectstack-ai/objectstack#1604 / ADR-0034 item 4).
|
|
2435
3472
|
* Runs the operations in ONE server transaction — commit all or roll back
|
|
2436
3473
|
* all. A field value of `{ $ref: <earlier op index> }` resolves to that op's
|
|
2437
3474
|
* created id, so a child can reference its parent created earlier in the same
|
|
2438
3475
|
* batch (master-detail).
|
|
2439
3476
|
*
|
|
2440
3477
|
* Transport: the published `@objectstack/client` SDK method
|
|
2441
|
-
* `data.batchTransaction` (
|
|
3478
|
+
* `data.batchTransaction` (objectstack-ai/objectstack#3271; shipped since client v16, our
|
|
2442
3479
|
* dependency floor). Per AGENTS.md §7 data always flows through the client —
|
|
2443
3480
|
* never a hand-rolled `fetch('/api/v1/batch')`.
|
|
2444
3481
|
*
|
|
2445
|
-
* Fallback decision — declarative capability negotiation (
|
|
3482
|
+
* Fallback decision — declarative capability negotiation (objectstack-ai/objectstack#3298 /
|
|
2446
3483
|
* objectui #2693). At connect() we read `capabilities.transactionalBatch`
|
|
2447
3484
|
* from discovery:
|
|
2448
3485
|
* - Declared `true` → the backend GUARANTEES atomicity (declared ===
|
|
2449
3486
|
* enforced). We TRUST it: any batch failure — including 404/405/501 —
|
|
2450
3487
|
* surfaces as a real error. No non-atomic client-side compensation. This
|
|
2451
3488
|
* is the path modern backends take.
|
|
2452
|
-
* - Declared `false`, or ABSENT (backend predates #3298) → we can't rely on
|
|
3489
|
+
* - Declared `false`, or ABSENT (backend predates objectstack-ai/objectstack#3298) → we can't rely on
|
|
2453
3490
|
* server atomicity, so we keep the legacy behaviour: on 404/405 (no
|
|
2454
3491
|
* endpoint) or 501 (runtime without transactions) degrade to the
|
|
2455
3492
|
* client-side, NON-atomic {@link emulateBatchTransaction} so a save is
|
|
@@ -2873,9 +3910,10 @@ var ObjectStackAdapter = class {
|
|
|
2873
3910
|
const res = await this.fetchImpl(url, { method: "GET", headers });
|
|
2874
3911
|
if (!res.ok) {
|
|
2875
3912
|
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
|
|
2876
|
-
const
|
|
3913
|
+
const envelope = readErrorEnvelope(errorBody);
|
|
3914
|
+
const err = new Error(envelope.message ?? res.statusText);
|
|
2877
3915
|
err.status = res.status;
|
|
2878
|
-
err.code =
|
|
3916
|
+
err.code = envelope.code;
|
|
2879
3917
|
err.httpStatus = res.status;
|
|
2880
3918
|
throw err;
|
|
2881
3919
|
}
|
|
@@ -2925,16 +3963,51 @@ var ObjectStackAdapter = class {
|
|
|
2925
3963
|
queryParams.set("searchFields", request.searchFields.join(","));
|
|
2926
3964
|
}
|
|
2927
3965
|
}
|
|
3966
|
+
return this.fetchExportBlob(resource, queryParams);
|
|
3967
|
+
}
|
|
3968
|
+
/**
|
|
3969
|
+
* Download the server's import template:
|
|
3970
|
+
* `GET /api/v1/data/:object/export?template=true` (objectui#9600).
|
|
3971
|
+
*
|
|
3972
|
+
* Same route as {@link exportDownload}, with the server's template switch. It
|
|
3973
|
+
* sends only `template=true`: the server refuses the export's row parameters
|
|
3974
|
+
* on a template request, and its answer is always an xlsx workbook.
|
|
3975
|
+
*
|
|
3976
|
+
* The server writes the template's labels in the request's locale, and the
|
|
3977
|
+
* import accepts a translated label only in its own request's locale. The
|
|
3978
|
+
* import request goes through `@objectstack/client`, which stamps
|
|
3979
|
+
* `Accept-Language` with the client's locale when one is set (and the host's
|
|
3980
|
+
* `fetch` stamps its own otherwise). This request bypasses the client, so it
|
|
3981
|
+
* stamps the client's locale the same way. Both requests then carry the same
|
|
3982
|
+
* locale.
|
|
3983
|
+
*/
|
|
3984
|
+
async downloadImportTemplate(resource) {
|
|
3985
|
+
const locale = this.client.getLocale();
|
|
3986
|
+
return this.fetchExportBlob(
|
|
3987
|
+
resource,
|
|
3988
|
+
new URLSearchParams({ template: "true" }),
|
|
3989
|
+
locale ? { "Accept-Language": locale } : void 0
|
|
3990
|
+
);
|
|
3991
|
+
}
|
|
3992
|
+
/**
|
|
3993
|
+
* GET `/api/v1/data/:object/export` with the given query and answer the body
|
|
3994
|
+
* as a Blob. A failure throws an `Error` that carries the server's `status`
|
|
3995
|
+
* and its ADR-0112 `code`. The one request path {@link exportDownload} and
|
|
3996
|
+
* {@link downloadImportTemplate} share.
|
|
3997
|
+
*/
|
|
3998
|
+
async fetchExportBlob(resource, queryParams, extraHeaders) {
|
|
2928
3999
|
const baseUrl = this.baseUrl.replace(/\/$/, "");
|
|
2929
4000
|
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
2930
4001
|
const dataPath = hasApiVersionSuffix ? "/data" : "/api/v1/data";
|
|
2931
4002
|
const url = `${baseUrl}${dataPath}/${encodeURIComponent(resource)}/export?${queryParams.toString()}`;
|
|
2932
|
-
const headers = { ...this.getAuthHeaders() };
|
|
4003
|
+
const headers = { ...this.getAuthHeaders(), ...extraHeaders };
|
|
2933
4004
|
const res = await this.fetchImpl(url, { method: "GET", headers, credentials: "include" });
|
|
2934
4005
|
if (!res.ok) {
|
|
2935
4006
|
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
|
|
2936
|
-
const
|
|
4007
|
+
const envelope = readErrorEnvelope(errorBody);
|
|
4008
|
+
const err = new Error(envelope.message ?? res.statusText);
|
|
2937
4009
|
err.status = res.status;
|
|
4010
|
+
err.code = envelope.code;
|
|
2938
4011
|
throw err;
|
|
2939
4012
|
}
|
|
2940
4013
|
return await res.blob();
|
|
@@ -2955,7 +4028,8 @@ var ObjectStackAdapter = class {
|
|
|
2955
4028
|
if (Array.isArray(params.$filter)) {
|
|
2956
4029
|
options.filters = translateFilterArray(params.$filter);
|
|
2957
4030
|
} else {
|
|
2958
|
-
|
|
4031
|
+
const lowered = convertFiltersToAST(params.$filter);
|
|
4032
|
+
if (lowered !== void 0) options.filters = lowered;
|
|
2959
4033
|
}
|
|
2960
4034
|
}
|
|
2961
4035
|
}
|
|
@@ -3075,13 +4149,16 @@ var ObjectStackAdapter = class {
|
|
|
3075
4149
|
});
|
|
3076
4150
|
if (!res.ok) {
|
|
3077
4151
|
const errBody = await res.json().catch(() => ({ message: res.statusText }));
|
|
3078
|
-
const
|
|
4152
|
+
const envelope = readErrorEnvelope(errBody);
|
|
4153
|
+
const err = new Error(envelope.message ?? res.statusText);
|
|
3079
4154
|
err.status = res.status;
|
|
4155
|
+
err.code = envelope.code;
|
|
3080
4156
|
throw err;
|
|
3081
4157
|
}
|
|
3082
4158
|
const body = await res.json();
|
|
3083
4159
|
const data = body && typeof body === "object" && "success" in body && "data" in body ? body.data : body;
|
|
3084
|
-
|
|
4160
|
+
const item = data && typeof data === "object" && "item" in data ? data.item : data;
|
|
4161
|
+
return attachObjectSortability(item, data && typeof data === "object" ? data.sortability : void 0);
|
|
3085
4162
|
}
|
|
3086
4163
|
/**
|
|
3087
4164
|
* List every registered object (code- and DB-defined) from the metadata
|
|
@@ -3308,7 +4385,42 @@ var ObjectStackAdapter = class {
|
|
|
3308
4385
|
* (vs. a system view being personalized for the first time). Omit /
|
|
3309
4386
|
* `false` for the default overlay-marking behavior.
|
|
3310
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
|
+
}
|
|
3311
4420
|
async updateViewConfig(objectName, viewId, config, opts) {
|
|
4421
|
+
if (!this.maySetViewConfig()) {
|
|
4422
|
+
throw new ViewConfigPermissionDeniedError(objectName, viewId);
|
|
4423
|
+
}
|
|
3312
4424
|
await this.connect();
|
|
3313
4425
|
const merged = {
|
|
3314
4426
|
...config || {},
|
|
@@ -3336,6 +4448,42 @@ var ObjectStackAdapter = class {
|
|
|
3336
4448
|
* the metadata index is name-only, not field-typed, so the route has no
|
|
3337
4449
|
* `?object=` to push the filter down into. {@link listViewOverrides}
|
|
3338
4450
|
* reads the same rows through the same accessor.
|
|
4451
|
+
*
|
|
4452
|
+
* ## Every failure still degrades to an empty list — and now says which kind
|
|
4453
|
+
* ## of failure it was (objectui#8151)
|
|
4454
|
+
*
|
|
4455
|
+
* The degrade is unchanged and deliberate, exactly as it is one method over
|
|
4456
|
+
* in {@link listImportMappings}: a host with no metadata door keeps answering
|
|
4457
|
+
* `[]` rather than breaking the page. What changed is that the OTHER failures
|
|
4458
|
+
* no longer render as that one.
|
|
4459
|
+
*
|
|
4460
|
+
* This method's empty list is read as *this object has no saved views*, and
|
|
4461
|
+
* that reading reaches the user in two places measured on this tree: the list
|
|
4462
|
+
* view switcher simply shows fewer tabs, and `@object-ui/core`'s
|
|
4463
|
+
* `elementDataSourceViewNotFoundMessage` states it outright. So a session
|
|
4464
|
+
* whose token lapsed mid-read was shown an object that appears to have no
|
|
4465
|
+
* saved views AT ALL — including ones the user created — with a
|
|
4466
|
+
* `console.warn` as the only discriminator, in a console nothing in the UI
|
|
4467
|
+
* points at. That is the silence objectui#7741 removed from the sibling after
|
|
4468
|
+
* it produced a confident wrong diagnosis in objectstack#14026.
|
|
4469
|
+
*
|
|
4470
|
+
* The `catch` now asks {@link classifyViewsFailure} what the failure WAS,
|
|
4471
|
+
* reading the error's own ADR-0112 `code` and status — never "is the result
|
|
4472
|
+
* empty", which is what both conditions produce and so can never tell them
|
|
4473
|
+
* apart — and anything that is not the doorless shape is announced on
|
|
4474
|
+
* {@link onMetadataReadWarning}.
|
|
4475
|
+
*
|
|
4476
|
+
* ⛔ That classifier is a SEPARATE reading, not a second caller of the
|
|
4477
|
+
* sibling's: `view`'s quiet set is strictly smaller, because the arm
|
|
4478
|
+
* `listImportMappings` is built around (*an older server without this kind*)
|
|
4479
|
+
* has no members here. See {@link classifyViewsFailure} for the measurement.
|
|
4480
|
+
*
|
|
4481
|
+
* ⛔ The RETURN is untouched. This method has answered `Promise<any[]>`, never
|
|
4482
|
+
* throwing, since `@object-ui/data-objectstack@17.1.0`; a consumer that reads
|
|
4483
|
+
* nothing new sees exactly what it saw before, including on the loud arms.
|
|
4484
|
+
* Applying framework objectstack#13906 decision 1 option A — *a thing that could not be
|
|
4485
|
+
* READ is not a thing that is ABSENT* — is done by ADDING a channel, not by
|
|
4486
|
+
* moving that contract.
|
|
3339
4487
|
*/
|
|
3340
4488
|
async listViews(objectName, options) {
|
|
3341
4489
|
await this.connect();
|
|
@@ -3361,18 +4509,35 @@ var ObjectStackAdapter = class {
|
|
|
3361
4509
|
const spec = v.list ?? v;
|
|
3362
4510
|
const isDraft = v._draft === true || spec?._draft === true;
|
|
3363
4511
|
if (spec && spec.config && typeof spec.config === "object") {
|
|
3364
|
-
|
|
4512
|
+
const row = {
|
|
3365
4513
|
...spec.config,
|
|
3366
4514
|
name: spec.name ?? spec.config.name,
|
|
3367
|
-
label: spec.label ?? spec.config.label
|
|
3368
|
-
isDefault: !!spec.isDefault,
|
|
3369
|
-
...isDraft ? { _draft: true } : {}
|
|
4515
|
+
label: spec.label ?? spec.config.label
|
|
3370
4516
|
};
|
|
4517
|
+
if (spec.object !== void 0) row.object = spec.object;
|
|
4518
|
+
for (const key of VIEW_ITEM_ROUND_TRIP_KEYS) {
|
|
4519
|
+
if (spec[key] !== void 0) row[key] = spec[key];
|
|
4520
|
+
}
|
|
4521
|
+
row.isDefault = !!spec.isDefault;
|
|
4522
|
+
if (isDraft) row._draft = true;
|
|
4523
|
+
return row;
|
|
3371
4524
|
}
|
|
3372
4525
|
return isDraft ? { ...spec, _draft: true } : spec;
|
|
3373
4526
|
});
|
|
3374
4527
|
} catch (err) {
|
|
3375
4528
|
console.warn("[OBJECTSTACKDataSource] listViews failed:", err);
|
|
4529
|
+
const failure = classifyViewsFailure(err);
|
|
4530
|
+
if (failure.kind !== "not-served") {
|
|
4531
|
+
this.emitMetadataReadWarning({
|
|
4532
|
+
operation: "listViews",
|
|
4533
|
+
kind: "view",
|
|
4534
|
+
objectName,
|
|
4535
|
+
reason: failure.kind,
|
|
4536
|
+
...failure.code !== void 0 ? { code: failure.code } : {},
|
|
4537
|
+
...failure.status !== void 0 ? { status: failure.status } : {},
|
|
4538
|
+
...failure.message !== void 0 ? { message: failure.message } : {}
|
|
4539
|
+
});
|
|
4540
|
+
}
|
|
3376
4541
|
return [];
|
|
3377
4542
|
}
|
|
3378
4543
|
}
|
|
@@ -3391,11 +4556,36 @@ var ObjectStackAdapter = class {
|
|
|
3391
4556
|
}
|
|
3392
4557
|
/**
|
|
3393
4558
|
* List registered import `mapping` artifacts targeting a given object
|
|
3394
|
-
* (
|
|
4559
|
+
* (objectstack-ai/objectstack#2611). Reads the `mapping` metadata kind via the overlay API
|
|
3395
4560
|
* and filters by `targetObject` client-side (the metadata index is
|
|
3396
|
-
* name-only). Feeds the import wizard's "saved mapping" selector
|
|
3397
|
-
*
|
|
3398
|
-
*
|
|
4561
|
+
* name-only). Feeds the import wizard's "saved mapping" selector.
|
|
4562
|
+
*
|
|
4563
|
+
* ## Every failure still degrades to an empty list — and now says which kind
|
|
4564
|
+
* ## of failure it was (objectui#7741)
|
|
4565
|
+
*
|
|
4566
|
+
* The degrade is unchanged and deliberate: an older server without the
|
|
4567
|
+
* `mapping` kind must not break the wizard, so it keeps answering `[]` and
|
|
4568
|
+
* the selector simply doesn't appear. That is a real, supported deployment
|
|
4569
|
+
* shape and it stays quiet.
|
|
4570
|
+
*
|
|
4571
|
+
* What changed is that the OTHER failures no longer render as that one. A
|
|
4572
|
+
* refused door and a broken one used to be indistinguishable, in the UI, from
|
|
4573
|
+
* "no mapping is registered" — both were an empty list behind a
|
|
4574
|
+
* `console.warn` nothing pointed at — and that silence produced a confident
|
|
4575
|
+
* wrong diagnosis in a careful reporter (objectstack#14026 was filed, routed
|
|
4576
|
+
* and worked by two seats against a wizard that had been correct since
|
|
4577
|
+
* 17.1.0). So the `catch` now asks {@link classifyImportMappingsFailure} what
|
|
4578
|
+
* the failure WAS, reading the error's own ADR-0112 `code` and status —
|
|
4579
|
+
* never "is the result empty", which is what both conditions produce and so
|
|
4580
|
+
* can never tell them apart — and anything that is not the supported
|
|
4581
|
+
* kind-absent shape is announced on {@link onMetadataReadWarning}.
|
|
4582
|
+
*
|
|
4583
|
+
* ⛔ The RETURN is untouched. This method has answered `Promise<any[]>`, never
|
|
4584
|
+
* throwing, since `@object-ui/data-objectstack@17.1.0`; a consumer that reads
|
|
4585
|
+
* nothing new sees exactly what it saw before, including on the loud arms.
|
|
4586
|
+
* Applying framework objectstack#13906 decision 1 option A — *a thing that could not be
|
|
4587
|
+
* READ is not a thing that is ABSENT* — is done by ADDING a channel, not by
|
|
4588
|
+
* moving that contract.
|
|
3399
4589
|
*/
|
|
3400
4590
|
async listImportMappings(objectName) {
|
|
3401
4591
|
await this.connect();
|
|
@@ -3405,6 +4595,18 @@ var ObjectStackAdapter = class {
|
|
|
3405
4595
|
return items.filter((m) => m && m.targetObject === objectName);
|
|
3406
4596
|
} catch (err) {
|
|
3407
4597
|
console.warn("[OBJECTSTACKDataSource] listImportMappings failed:", err);
|
|
4598
|
+
const failure = classifyImportMappingsFailure(err);
|
|
4599
|
+
if (failure.kind !== "not-served") {
|
|
4600
|
+
this.emitMetadataReadWarning({
|
|
4601
|
+
operation: "listImportMappings",
|
|
4602
|
+
kind: "mapping",
|
|
4603
|
+
objectName,
|
|
4604
|
+
reason: failure.kind,
|
|
4605
|
+
...failure.code !== void 0 ? { code: failure.code } : {},
|
|
4606
|
+
...failure.status !== void 0 ? { status: failure.status } : {},
|
|
4607
|
+
...failure.message !== void 0 ? { message: failure.message } : {}
|
|
4608
|
+
});
|
|
4609
|
+
}
|
|
3408
4610
|
return [];
|
|
3409
4611
|
}
|
|
3410
4612
|
}
|
|
@@ -3659,11 +4861,17 @@ var ObjectStackAdapter = class {
|
|
|
3659
4861
|
*
|
|
3660
4862
|
* The maintainer ruling (2026-08-12) put the answer on the BY-NAME route
|
|
3661
4863
|
* rather than in the list, so the enumeration surface is not widened past what
|
|
3662
|
-
* a by-name probe already implies (objectstack#8013 / PR #8135): an app that
|
|
4864
|
+
* a by-name probe already implies (objectstack#8013 / PR objectstack-ai/objectstack#8135): an app that
|
|
3663
4865
|
* exists and whose `requiredPermissions` the session lacks answers `403` with
|
|
3664
4866
|
* `PERMISSION_DENIED` in the declared envelope, and absence — a nonexistent
|
|
3665
|
-
* name, an unpublished app, an app gated by an absent optional service —
|
|
3666
|
-
*
|
|
4867
|
+
* name, an unpublished app, an app gated by an absent optional service — is
|
|
4868
|
+
* one undifferentiated answer.
|
|
4869
|
+
*
|
|
4870
|
+
* ⚠️ That absence is NOT uniformly a `404`, which this docstring asserted
|
|
4871
|
+
* until objectui#9262 measured it: the 404 covers an app that EXISTS and is
|
|
4872
|
+
* withheld for a non-permission reason, while a name that resolves to nothing
|
|
4873
|
+
* answers `200` with an envelope carrying no `item`. Both are absence and both
|
|
4874
|
+
* read as `not_found`; see {@link metaEnvelopeCarriesItem} for the measurement.
|
|
3667
4875
|
*
|
|
3668
4876
|
* ## Why this is a separate method and not a flavour of {@link getApp}
|
|
3669
4877
|
*
|
|
@@ -3674,19 +4882,41 @@ var ObjectStackAdapter = class {
|
|
|
3674
4882
|
* be cached beside a document about the APP — one grant, and a cached
|
|
3675
4883
|
* denial outlives the session it described.
|
|
3676
4884
|
*
|
|
3677
|
-
* Nothing here throws:
|
|
3678
|
-
*
|
|
3679
|
-
*
|
|
4885
|
+
* Nothing here throws: every outcome is a verdict the caller renders a screen
|
|
4886
|
+
* from, never a catch block. Only the measured `code` produces `denied` —
|
|
4887
|
+
* never a status, never a message (objectui#4408).
|
|
4888
|
+
*
|
|
4889
|
+
* ## objectui#9262 — what this method stopped folding away
|
|
4890
|
+
*
|
|
4891
|
+
* It used to answer `granted` whenever the call did not throw, and `unknown`
|
|
4892
|
+
* for everything that did. Both halves were wrong in the same direction, and
|
|
4893
|
+
* the first was measured on a real server before this change landed:
|
|
4894
|
+
*
|
|
4895
|
+
* - **an app that does not exist answers `200` with the envelope minus its
|
|
4896
|
+
* `item`**, so "did not throw" reported `granted` for a name with nothing
|
|
4897
|
+
* behind it — the shape issue objectui#9262 listed as cause 8 and expected
|
|
4898
|
+
* to be structural. It is the ordinary case: every typo, every never-created
|
|
4899
|
+
* app and every unpublished draft took it. See
|
|
4900
|
+
* {@link metaEnvelopeCarriesItem} for the measurement and the server-side
|
|
4901
|
+
* mechanism.
|
|
4902
|
+
* - a 404 absence and an unreachable server were one `unknown`, though the
|
|
4903
|
+
* transport had already told them apart.
|
|
4904
|
+
*
|
|
4905
|
+
* The verdicts now say only what was obtained: a served document, a refusal
|
|
4906
|
+
* naming permission, an answer that there is no such app here, or no answer
|
|
4907
|
+
* at all. `unknown` is left for the one case where nothing was asked.
|
|
3680
4908
|
*
|
|
3681
4909
|
* @param appName - the app name as it appears in the URL segment
|
|
3682
4910
|
*/
|
|
3683
4911
|
async probeAppAccess(appName) {
|
|
3684
4912
|
if (!appName) return "unknown";
|
|
3685
4913
|
try {
|
|
3686
|
-
await this.client.meta.getItem("app", appName);
|
|
3687
|
-
return "granted";
|
|
4914
|
+
const envelope = await this.client.meta.getItem("app", appName);
|
|
4915
|
+
return metaEnvelopeCarriesItem(envelope) ? "granted" : "not_found";
|
|
3688
4916
|
} catch (err) {
|
|
3689
|
-
|
|
4917
|
+
if (isAppPermissionDeniedError(err)) return "denied";
|
|
4918
|
+
if (isMetaItemAbsentError(err)) return "not_found";
|
|
4919
|
+
return "unreachable";
|
|
3690
4920
|
}
|
|
3691
4921
|
}
|
|
3692
4922
|
/**
|
|
@@ -3736,7 +4966,8 @@ var ObjectStackAdapter = class {
|
|
|
3736
4966
|
* Uses `this.client.analytics.query()` from @objectstack/client to leverage
|
|
3737
4967
|
* the SDK's built-in auth, headers, and fetch configuration.
|
|
3738
4968
|
* Falls back to client-side aggregation via find() if the analytics endpoint
|
|
3739
|
-
* is not available.
|
|
4969
|
+
* is not available. Throws {@link AnalyticsResultShapeError} when it answers
|
|
4970
|
+
* with anything but an `AnalyticsResult` carrying `rows`.
|
|
3740
4971
|
*/
|
|
3741
4972
|
async aggregate(resource, params) {
|
|
3742
4973
|
await this.connect();
|
|
@@ -3745,7 +4976,11 @@ var ObjectStackAdapter = class {
|
|
|
3745
4976
|
const queryAst = {};
|
|
3746
4977
|
if (Array.isArray(params.groupBy)) queryAst.groupBy = params.groupBy;
|
|
3747
4978
|
if (Array.isArray(params.aggregations)) queryAst.aggregations = params.aggregations;
|
|
3748
|
-
if (params.where !== void 0)
|
|
4979
|
+
if (params.where !== void 0) {
|
|
4980
|
+
assertSpecShapeWhereIsFilterAst(params.where, resource);
|
|
4981
|
+
queryAst.where = params.where;
|
|
4982
|
+
}
|
|
4983
|
+
assertNoAnalyticsKeysOnSpecShape(params, resource);
|
|
3749
4984
|
if (typeof params.limit === "number") queryAst.limit = params.limit;
|
|
3750
4985
|
const result = await this.client.data.query(resource, queryAst);
|
|
3751
4986
|
if (Array.isArray(result)) return result;
|
|
@@ -3753,6 +4988,7 @@ var ObjectStackAdapter = class {
|
|
|
3753
4988
|
if (Array.isArray(result?.data)) return result.data;
|
|
3754
4989
|
return [];
|
|
3755
4990
|
}
|
|
4991
|
+
const analyticsWhere = params.filter ? lowerAnalyticsFilterForWire(params.filter, resource) : void 0;
|
|
3756
4992
|
try {
|
|
3757
4993
|
const measureName = params.function === "count" ? "count" : `${params.field}_${params.function}`;
|
|
3758
4994
|
const valueKey = this.aggregateValueKey(params);
|
|
@@ -3762,11 +4998,12 @@ var ObjectStackAdapter = class {
|
|
|
3762
4998
|
// When groupBy is '_all' no dimensions are needed (single-bucket).
|
|
3763
4999
|
dimensions: params.groupBy && params.groupBy !== "_all" ? [params.groupBy] : []
|
|
3764
5000
|
};
|
|
3765
|
-
if (
|
|
3766
|
-
|
|
5001
|
+
if (analyticsWhere !== void 0) payload.where = analyticsWhere;
|
|
5002
|
+
const contractResult = await this.client.analytics.query(payload);
|
|
5003
|
+
if (!Array.isArray(contractResult?.rows)) {
|
|
5004
|
+
throw new AnalyticsResultShapeError(resource, contractResult);
|
|
3767
5005
|
}
|
|
3768
|
-
const
|
|
3769
|
-
const rawRows = Array.isArray(data) ? data : data?.rows && Array.isArray(data.rows) ? data.rows : data?.data && Array.isArray(data.data) ? data.data : data?.data?.rows && Array.isArray(data.data.rows) ? data.data.rows : data?.results && Array.isArray(data.results) ? data.results : [];
|
|
5006
|
+
const rawRows = contractResult.rows;
|
|
3770
5007
|
const measureMissing = rawRows.length > 0 && rawRows.every((row) => {
|
|
3771
5008
|
if (row == null) return true;
|
|
3772
5009
|
if (measureName in row && row[measureName] != null) return false;
|
|
@@ -3785,6 +5022,7 @@ var ObjectStackAdapter = class {
|
|
|
3785
5022
|
return mapped;
|
|
3786
5023
|
});
|
|
3787
5024
|
} catch (e) {
|
|
5025
|
+
if (e instanceof AnalyticsResultShapeError) throw e;
|
|
3788
5026
|
const failure = classifyAnalyticsFailure(e);
|
|
3789
5027
|
if (failure.kind === "rejected") {
|
|
3790
5028
|
throw new AnalyticsQueryRejectedError(failure.message, failure.code);
|
|
@@ -3804,6 +5042,34 @@ var ObjectStackAdapter = class {
|
|
|
3804
5042
|
return await this.aggregateViaFind(resource, params);
|
|
3805
5043
|
}
|
|
3806
5044
|
}
|
|
5045
|
+
/**
|
|
5046
|
+
* Answer a grouped list view's GROUP HEADER query (objectui#7189) — see
|
|
5047
|
+
* `DataSource.queryGroupHeaders` in `@object-ui/types` for the contract.
|
|
5048
|
+
*
|
|
5049
|
+
* The compiled query rides the platform's EXISTING data door verbatim:
|
|
5050
|
+
* `client.data.query()` posts it to `POST /data/:object/query`, whose
|
|
5051
|
+
* `findData` routes a body carrying `groupBy` / `aggregations` to
|
|
5052
|
+
* `engine.aggregate` and answers the header rows as `records` (the door the
|
|
5053
|
+
* spec half of objectstack#14556 names, pinned platform-side by
|
|
5054
|
+
* objectstack#15330). No new route, no new wire shape.
|
|
5055
|
+
*
|
|
5056
|
+
* ⛔ No fallback, deliberately — unlike {@link aggregate}. A header query that
|
|
5057
|
+
* degraded to bucketing a fetched page would answer page slices under a name
|
|
5058
|
+
* that promises the query's own numbers, which is the defect this member
|
|
5059
|
+
* exists to end. A failure is thrown to the caller, and a body without a
|
|
5060
|
+
* `records` array is refused rather than read as "no groups".
|
|
5061
|
+
*/
|
|
5062
|
+
async queryGroupHeaders(resource, query) {
|
|
5063
|
+
await this.connect();
|
|
5064
|
+
const result = await this.client.data.query(resource, query);
|
|
5065
|
+
const records = result?.records;
|
|
5066
|
+
if (!Array.isArray(records)) {
|
|
5067
|
+
throw new Error(
|
|
5068
|
+
`queryGroupHeaders('${resource}'): POST /data/${resource}/query answered without a \`records\` array, so no group header can be read from it. The header query was sent verbatim; nothing was approximated from a page of rows.`
|
|
5069
|
+
);
|
|
5070
|
+
}
|
|
5071
|
+
return records;
|
|
5072
|
+
}
|
|
3807
5073
|
/**
|
|
3808
5074
|
* Client-side aggregation over a server-scoped `find()` — the fallback used
|
|
3809
5075
|
* whenever the analytics endpoint cannot answer (capability absent, network
|
|
@@ -3899,6 +5165,13 @@ var ObjectStackAdapter = class {
|
|
|
3899
5165
|
if (errorCodeIs({ code: serverCode }, "UNAUTHENTICATED")) {
|
|
3900
5166
|
throw new AnalyticsUnauthenticatedError({ serverCode, serverMessage });
|
|
3901
5167
|
}
|
|
5168
|
+
if (errorCodeIs({ code: serverCode }, "PERMISSION_DENIED")) {
|
|
5169
|
+
throw new AnalyticsForbiddenError({
|
|
5170
|
+
serverCode,
|
|
5171
|
+
serverMessage,
|
|
5172
|
+
datasetName: requestedDatasetName
|
|
5173
|
+
});
|
|
5174
|
+
}
|
|
3902
5175
|
if (serverCode === void 0) {
|
|
3903
5176
|
if (ANALYTICS_ABSENT_STATUSES.includes(res.status)) {
|
|
3904
5177
|
throw new AnalyticsNotInstalledError(surface, detail || void 0);
|
|
@@ -4039,9 +5312,10 @@ var ObjectStackAdapter = class {
|
|
|
4039
5312
|
}
|
|
4040
5313
|
});
|
|
4041
5314
|
if (!response.ok) {
|
|
4042
|
-
const
|
|
5315
|
+
const errorBody = await response.json().catch(() => ({ message: response.statusText }));
|
|
5316
|
+
const envelope = readErrorEnvelope(errorBody);
|
|
4043
5317
|
throw new ObjectStackError(
|
|
4044
|
-
|
|
5318
|
+
envelope.message ?? `Upload failed with status ${response.status}`,
|
|
4045
5319
|
"UPLOAD_ERROR",
|
|
4046
5320
|
response.status
|
|
4047
5321
|
);
|
|
@@ -4081,9 +5355,10 @@ var ObjectStackAdapter = class {
|
|
|
4081
5355
|
}
|
|
4082
5356
|
});
|
|
4083
5357
|
if (!response.ok) {
|
|
4084
|
-
const
|
|
5358
|
+
const errorBody = await response.json().catch(() => ({ message: response.statusText }));
|
|
5359
|
+
const envelope = readErrorEnvelope(errorBody);
|
|
4085
5360
|
throw new ObjectStackError(
|
|
4086
|
-
|
|
5361
|
+
envelope.message ?? `Upload failed with status ${response.status}`,
|
|
4087
5362
|
"UPLOAD_ERROR",
|
|
4088
5363
|
response.status
|
|
4089
5364
|
);
|
|
@@ -4166,28 +5441,46 @@ function createObjectStackAdapter(config) {
|
|
|
4166
5441
|
API_ACCESS_DENIED_CODES,
|
|
4167
5442
|
APP_PERMISSION_DENIED_CODE,
|
|
4168
5443
|
AnalyticsDatasetNotFoundError,
|
|
5444
|
+
AnalyticsForbiddenError,
|
|
5445
|
+
AnalyticsKeysOnSpecShapeError,
|
|
4169
5446
|
AnalyticsNotInstalledError,
|
|
4170
5447
|
AnalyticsQueryRejectedError,
|
|
5448
|
+
AnalyticsResultShapeError,
|
|
4171
5449
|
AnalyticsUnauthenticatedError,
|
|
4172
5450
|
AuthenticationError,
|
|
4173
5451
|
BulkOperationError,
|
|
5452
|
+
CHOICE_TYPES_REQUIRING_OPTIONS,
|
|
4174
5453
|
ConcurrentUpdateError,
|
|
4175
5454
|
ConnectionError,
|
|
4176
5455
|
DataApiValidationError,
|
|
4177
5456
|
FILTER_OPERATOR_ALIASES,
|
|
5457
|
+
META_ITEM_ABSENT_CODE,
|
|
4178
5458
|
MalformedFilterError,
|
|
4179
5459
|
MetadataClient,
|
|
4180
5460
|
MetadataNotFoundError,
|
|
5461
|
+
OBJECT_METADATA_TYPE,
|
|
4181
5462
|
ObjectStackAdapter,
|
|
4182
5463
|
ObjectStackError,
|
|
5464
|
+
RELATIONSHIP_TYPES_REQUIRING_REFERENCE,
|
|
5465
|
+
UNRECOGNIZED_DROP_REASON,
|
|
5466
|
+
UnlowerableAnalyticsFilterError,
|
|
5467
|
+
UnloweredAggregateWhereError,
|
|
5468
|
+
VIEW_CONFIG_CAPABILITY,
|
|
4183
5469
|
VIEW_OVERLAY_OWNED_KEYS,
|
|
5470
|
+
ViewConfigPermissionDeniedError,
|
|
5471
|
+
assertObjectMetadataWritable,
|
|
4184
5472
|
classifyAnalyticsFailure,
|
|
5473
|
+
classifyImportMappingsFailure,
|
|
5474
|
+
classifyViewsFailure,
|
|
4185
5475
|
clearSharedDiscoveryCache,
|
|
4186
5476
|
createErrorFromResponse,
|
|
4187
5477
|
createObjectStackAdapter,
|
|
4188
5478
|
createObjectStackUserStateAdapter,
|
|
4189
5479
|
createQuietHttpLogger,
|
|
5480
|
+
extractDraftBody,
|
|
4190
5481
|
formatHttpFailureMessage,
|
|
5482
|
+
formatMetadataError,
|
|
5483
|
+
formatMetadataIssue,
|
|
4191
5484
|
getSharedDiscovery,
|
|
4192
5485
|
is404Error,
|
|
4193
5486
|
isAnalyticsNotInstalledError,
|
|
@@ -4196,7 +5489,9 @@ function createObjectStackAdapter(config) {
|
|
|
4196
5489
|
isConcurrentUpdateError,
|
|
4197
5490
|
isErrorType,
|
|
4198
5491
|
isMalformedFilterError,
|
|
5492
|
+
isMetaItemAbsentError,
|
|
4199
5493
|
isObjectStackError,
|
|
5494
|
+
isViewConfigPermissionDeniedError,
|
|
4200
5495
|
narrowPersonalizationOverlay,
|
|
4201
5496
|
normaliseClientError,
|
|
4202
5497
|
readSaveAdvisories,
|