@object-ui/data-objectstack 17.2.0 → 17.5.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 +772 -0
- package/README.md +3 -11
- package/dist/index.cjs +1232 -699
- package/dist/index.d.cts +1367 -906
- package/dist/index.d.ts +1367 -906
- package/dist/index.js +1222 -696
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +5 -5
- package/src/apiAccessDenied.test.ts +233 -0
- package/src/appAccessProbe.test.ts +147 -0
- package/src/cloud-surface-retired-4152.pin.test.ts +171 -0
- package/src/deleteView.homes.test.ts +389 -0
- package/src/deleteViewContract.types.test.ts +246 -0
- package/src/filter-operator-ast-parity.test.ts +75 -11
- package/src/httpFailureLogging.test.ts +109 -0
- package/src/index.ts +967 -100
- package/src/listViewOverrides.test.ts +241 -0
- package/src/metadata-client.get-envelope.test.ts +171 -0
- package/src/metadata-client.saveAdvisories.test.ts +226 -0
- package/src/metadata-client.ts +208 -11
- package/src/onMutation.test.ts +29 -2
- package/src/onSaveAdvisory.test.ts +311 -0
- package/src/onWriteWarning.test.ts +101 -0
- package/src/queryDataset.test.ts +411 -0
- package/src/spec-symbol-batch6.test.ts +25 -2
- package/src/updateView.draft.test.ts +242 -0
- package/src/v3-compat.test.ts +10 -21
- package/src/viewCacheInvalidation.pin.test.ts +260 -0
- package/src/viewInvalidationSeam.pin.test.ts +162 -0
- package/src/cloud.ts +0 -109
package/dist/index.cjs
CHANGED
|
@@ -22,11 +22,12 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
|
|
|
22
22
|
// src/index.ts
|
|
23
23
|
var index_exports = {};
|
|
24
24
|
__export(index_exports, {
|
|
25
|
+
API_ACCESS_DENIED_CODES: () => API_ACCESS_DENIED_CODES,
|
|
26
|
+
APP_PERMISSION_DENIED_CODE: () => APP_PERMISSION_DENIED_CODE,
|
|
25
27
|
AnalyticsNotInstalledError: () => AnalyticsNotInstalledError,
|
|
26
28
|
AnalyticsQueryRejectedError: () => AnalyticsQueryRejectedError,
|
|
27
29
|
AuthenticationError: () => AuthenticationError,
|
|
28
30
|
BulkOperationError: () => BulkOperationError,
|
|
29
|
-
CloudOperations: () => CloudOperations,
|
|
30
31
|
ConcurrentUpdateError: () => ConcurrentUpdateError,
|
|
31
32
|
ConnectionError: () => ConnectionError,
|
|
32
33
|
DataApiValidationError: () => DataApiValidationError,
|
|
@@ -45,375 +46,40 @@ __export(index_exports, {
|
|
|
45
46
|
createErrorFromResponse: () => createErrorFromResponse,
|
|
46
47
|
createObjectStackAdapter: () => createObjectStackAdapter,
|
|
47
48
|
createObjectStackUserStateAdapter: () => createObjectStackUserStateAdapter,
|
|
49
|
+
createQuietHttpLogger: () => createQuietHttpLogger,
|
|
50
|
+
formatHttpFailureMessage: () => formatHttpFailureMessage,
|
|
48
51
|
generateContractManifest: () => generateContractManifest,
|
|
49
52
|
getSharedDiscovery: () => getSharedDiscovery,
|
|
50
53
|
is404Error: () => is404Error,
|
|
51
54
|
isAnalyticsNotInstalledError: () => isAnalyticsNotInstalledError,
|
|
55
|
+
isApiAccessDeniedError: () => isApiAccessDeniedError,
|
|
56
|
+
isAppPermissionDeniedError: () => isAppPermissionDeniedError,
|
|
52
57
|
isConcurrentUpdateError: () => isConcurrentUpdateError,
|
|
53
58
|
isErrorType: () => isErrorType,
|
|
54
59
|
isMalformedFilterError: () => isMalformedFilterError,
|
|
55
60
|
isObjectStackError: () => isObjectStackError,
|
|
56
61
|
normaliseClientError: () => normaliseClientError,
|
|
62
|
+
readSaveAdvisories: () => readSaveAdvisories,
|
|
57
63
|
readTransactionalBatchCapability: () => readTransactionalBatchCapability,
|
|
58
64
|
serializeOrderBy: () => serializeOrderBy,
|
|
59
65
|
snapToGrid: () => snapToGrid,
|
|
60
|
-
validatePluginContract: () => validatePluginContract
|
|
66
|
+
validatePluginContract: () => validatePluginContract,
|
|
67
|
+
viewItemObjectName: () => viewItemObjectName
|
|
61
68
|
});
|
|
62
69
|
module.exports = __toCommonJS(index_exports);
|
|
63
70
|
var import_client = require("@objectstack/client");
|
|
64
71
|
|
|
65
|
-
// ../types/src/index.ts
|
|
66
|
-
var import_spec = require("@objectstack/spec");
|
|
67
|
-
|
|
68
|
-
// ../types/src/error-code.ts
|
|
69
|
-
function errorCodeIs(err, code) {
|
|
70
|
-
const raw = err?.code;
|
|
71
|
-
if (typeof raw !== "string" || raw.length === 0) return false;
|
|
72
|
-
return raw.toUpperCase() === code.toUpperCase();
|
|
73
|
-
}
|
|
74
|
-
function errorCodeIsAnyOf(err, codes) {
|
|
75
|
-
return codes.some((c) => errorCodeIs(err, c));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ../core/src/utils/filter-converter.ts
|
|
79
|
-
var FilterOperatorError = class extends Error {
|
|
80
|
-
constructor(message) {
|
|
81
|
-
super(message);
|
|
82
|
-
__publicField(this, "code", "INVALID_FILTER");
|
|
83
|
-
__publicField(this, "httpStatus", 400);
|
|
84
|
-
this.name = "FilterOperatorError";
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
function convertOperatorToAST(operator) {
|
|
88
|
-
const operatorMap = {
|
|
89
|
-
"$eq": "=",
|
|
90
|
-
"$ne": "!=",
|
|
91
|
-
"$gt": ">",
|
|
92
|
-
"$gte": ">=",
|
|
93
|
-
"$lt": "<",
|
|
94
|
-
"$lte": "<=",
|
|
95
|
-
"$in": "in",
|
|
96
|
-
"$nin": "nin",
|
|
97
|
-
"$notin": "nin",
|
|
98
|
-
"$between": "between",
|
|
99
|
-
"$contains": "contains",
|
|
100
|
-
"$notContains": "notcontains",
|
|
101
|
-
"$notcontains": "notcontains",
|
|
102
|
-
"$startsWith": "startswith",
|
|
103
|
-
"$startswith": "startswith",
|
|
104
|
-
"$endsWith": "endswith",
|
|
105
|
-
"$endswith": "endswith"
|
|
106
|
-
};
|
|
107
|
-
return operatorMap[operator] || null;
|
|
108
|
-
}
|
|
109
|
-
function convertFiltersToAST(filter) {
|
|
110
|
-
const conditions = [];
|
|
111
|
-
for (const [field, value] of Object.entries(filter)) {
|
|
112
|
-
if (value === null || value === void 0) continue;
|
|
113
|
-
if (typeof value === "object" && !Array.isArray(value)) {
|
|
114
|
-
for (const [operator, operatorValue] of Object.entries(value)) {
|
|
115
|
-
if (operator === "$regex") {
|
|
116
|
-
throw new FilterOperatorError(
|
|
117
|
-
`[ObjectUI] The '$regex' filter operator is not supported. It used to be converted to 'contains', which matches a literal substring rather than a pattern \u2014 a different result, not a degraded one. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. Use $contains, $startsWith or $endsWith.`
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
if (operator === "$null") {
|
|
121
|
-
conditions.push([field, operatorValue ? "is_null" : "is_not_null", true]);
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
if (operator === "$exists") {
|
|
125
|
-
conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
const astOperator = convertOperatorToAST(operator);
|
|
129
|
-
if (astOperator) {
|
|
130
|
-
conditions.push([field, astOperator, operatorValue]);
|
|
131
|
-
} else {
|
|
132
|
-
throw new FilterOperatorError(
|
|
133
|
-
`[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.`
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
} else {
|
|
138
|
-
conditions.push([field, "=", value]);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
if (conditions.length === 0) {
|
|
142
|
-
return filter;
|
|
143
|
-
}
|
|
144
|
-
if (conditions.length === 1) {
|
|
145
|
-
return conditions[0];
|
|
146
|
-
}
|
|
147
|
-
return ["and", ...conditions];
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ../core/src/adapters/batchTransaction.ts
|
|
151
|
-
function idOf(rec) {
|
|
152
|
-
if (rec == null) return void 0;
|
|
153
|
-
return rec.id ?? rec._id ?? rec.recordId;
|
|
154
|
-
}
|
|
155
|
-
function resolveRefs(data, results) {
|
|
156
|
-
const out = {};
|
|
157
|
-
for (const [key, value] of Object.entries(data)) {
|
|
158
|
-
if (value && typeof value === "object" && !Array.isArray(value) && typeof value.$ref === "number") {
|
|
159
|
-
const refIndex = value.$ref;
|
|
160
|
-
const id = idOf(results[refIndex]);
|
|
161
|
-
if (id === void 0) {
|
|
162
|
-
throw new Error(
|
|
163
|
-
`batchTransaction: field "${key}" references operation ${refIndex}, which has not produced an id (forward or invalid $ref)`
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
out[key] = id;
|
|
167
|
-
} else {
|
|
168
|
-
out[key] = value;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return out;
|
|
172
|
-
}
|
|
173
|
-
async function emulateBatchTransaction(dataSource, operations) {
|
|
174
|
-
const results = [];
|
|
175
|
-
const created = [];
|
|
176
|
-
try {
|
|
177
|
-
for (const op of operations) {
|
|
178
|
-
const action = op.action ?? "create";
|
|
179
|
-
if (action === "create") {
|
|
180
|
-
const record = await dataSource.create(
|
|
181
|
-
op.object,
|
|
182
|
-
resolveRefs(op.data ?? {}, results)
|
|
183
|
-
);
|
|
184
|
-
results.push(record);
|
|
185
|
-
const id = idOf(record);
|
|
186
|
-
if (id) created.push({ object: op.object, id });
|
|
187
|
-
} else if (action === "update") {
|
|
188
|
-
if (op.id == null) {
|
|
189
|
-
throw new Error(`batchTransaction: update on "${op.object}" requires an id`);
|
|
190
|
-
}
|
|
191
|
-
results.push(
|
|
192
|
-
await dataSource.update(
|
|
193
|
-
op.object,
|
|
194
|
-
op.id,
|
|
195
|
-
resolveRefs(op.data ?? {}, results)
|
|
196
|
-
)
|
|
197
|
-
);
|
|
198
|
-
} else {
|
|
199
|
-
if (op.id == null) {
|
|
200
|
-
throw new Error(`batchTransaction: delete on "${op.object}" requires an id`);
|
|
201
|
-
}
|
|
202
|
-
results.push(await dataSource.delete(op.object, op.id));
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
return { results };
|
|
206
|
-
} catch (err) {
|
|
207
|
-
if (created.length > 0) {
|
|
208
|
-
await Promise.allSettled(
|
|
209
|
-
[...created].reverse().map((c) => dataSource.delete(c.object, c.id))
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
throw err;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// ../core/src/utils/reference-keys.ts
|
|
217
|
-
function normalizeFieldReferenceKeys(fieldDef) {
|
|
218
|
-
if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
|
|
219
|
-
const f = fieldDef;
|
|
220
|
-
const target = f.reference_to ?? f.reference ?? f.referenceTo;
|
|
221
|
-
if (target == null || target === "") return fieldDef;
|
|
222
|
-
if (f.reference_to === void 0) f.reference_to = target;
|
|
223
|
-
if (f.reference === void 0) f.reference = target;
|
|
224
|
-
return fieldDef;
|
|
225
|
-
}
|
|
226
|
-
function normalizeSchemaReferenceKeys(schema) {
|
|
227
|
-
const fields = schema && typeof schema === "object" ? schema.fields : null;
|
|
228
|
-
if (!fields || typeof fields !== "object") return schema;
|
|
229
|
-
const defs = Array.isArray(fields) ? fields : Object.values(fields);
|
|
230
|
-
for (const def of defs) normalizeFieldReferenceKeys(def);
|
|
231
|
-
return schema;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// src/cache/MetadataCache.ts
|
|
235
|
-
var MetadataCache = class {
|
|
236
|
-
/**
|
|
237
|
-
* Create a new MetadataCache instance
|
|
238
|
-
*
|
|
239
|
-
* @param options - Configuration options
|
|
240
|
-
* @param options.maxSize - Maximum number of entries (default: 100)
|
|
241
|
-
* @param options.ttl - Time to live in milliseconds (default: 5 minutes)
|
|
242
|
-
*/
|
|
243
|
-
constructor(options = {}) {
|
|
244
|
-
__publicField(this, "cache");
|
|
245
|
-
__publicField(this, "inflight");
|
|
246
|
-
__publicField(this, "maxSize");
|
|
247
|
-
__publicField(this, "ttl");
|
|
248
|
-
__publicField(this, "stats");
|
|
249
|
-
this.cache = /* @__PURE__ */ new Map();
|
|
250
|
-
this.inflight = /* @__PURE__ */ new Map();
|
|
251
|
-
this.maxSize = options.maxSize || 100;
|
|
252
|
-
this.ttl = options.ttl || 5 * 60 * 1e3;
|
|
253
|
-
this.stats = {
|
|
254
|
-
hits: 0,
|
|
255
|
-
misses: 0,
|
|
256
|
-
evictions: 0,
|
|
257
|
-
coalesced: 0
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
/**
|
|
261
|
-
* Get a value from cache or fetch it using the provided fetcher function
|
|
262
|
-
*
|
|
263
|
-
* @param key - Cache key
|
|
264
|
-
* @param fetcher - Async function to fetch data if not in cache
|
|
265
|
-
* @returns Promise resolving to the cached or fetched data
|
|
266
|
-
*/
|
|
267
|
-
async get(key, fetcher) {
|
|
268
|
-
const now = Date.now();
|
|
269
|
-
const cached = this.cache.get(key);
|
|
270
|
-
if (cached) {
|
|
271
|
-
const age = now - cached.timestamp;
|
|
272
|
-
if (age < this.ttl) {
|
|
273
|
-
cached.accessCount++;
|
|
274
|
-
cached.lastAccessed = now;
|
|
275
|
-
this.stats.hits++;
|
|
276
|
-
this.cache.delete(key);
|
|
277
|
-
this.cache.set(key, cached);
|
|
278
|
-
return cached.data;
|
|
279
|
-
} else {
|
|
280
|
-
this.cache.delete(key);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
const existing = this.inflight.get(key);
|
|
284
|
-
if (existing) {
|
|
285
|
-
this.stats.coalesced++;
|
|
286
|
-
return existing;
|
|
287
|
-
}
|
|
288
|
-
this.stats.misses++;
|
|
289
|
-
const promise = (async () => {
|
|
290
|
-
try {
|
|
291
|
-
const data = await fetcher();
|
|
292
|
-
this.set(key, data);
|
|
293
|
-
return data;
|
|
294
|
-
} finally {
|
|
295
|
-
this.inflight.delete(key);
|
|
296
|
-
}
|
|
297
|
-
})();
|
|
298
|
-
this.inflight.set(key, promise);
|
|
299
|
-
return promise;
|
|
300
|
-
}
|
|
301
|
-
/**
|
|
302
|
-
* Prime the cache with a pre-fetched value. Useful when a bulk endpoint
|
|
303
|
-
* (e.g. list of all object schemas) returns data that would otherwise
|
|
304
|
-
* be fetched again per item.
|
|
305
|
-
*/
|
|
306
|
-
prime(key, data) {
|
|
307
|
-
this.set(key, data);
|
|
308
|
-
}
|
|
309
|
-
/**
|
|
310
|
-
* Set a value in the cache
|
|
311
|
-
*
|
|
312
|
-
* @param key - Cache key
|
|
313
|
-
* @param data - Data to cache
|
|
314
|
-
*/
|
|
315
|
-
set(key, data) {
|
|
316
|
-
const now = Date.now();
|
|
317
|
-
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
|
|
318
|
-
this.evictLRU();
|
|
319
|
-
}
|
|
320
|
-
this.cache.set(key, {
|
|
321
|
-
data,
|
|
322
|
-
timestamp: now,
|
|
323
|
-
accessCount: 1,
|
|
324
|
-
lastAccessed: now
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
/**
|
|
328
|
-
* Evict the least recently used entry
|
|
329
|
-
*/
|
|
330
|
-
evictLRU() {
|
|
331
|
-
const firstKey = this.cache.keys().next().value;
|
|
332
|
-
if (firstKey !== void 0) {
|
|
333
|
-
this.cache.delete(firstKey);
|
|
334
|
-
this.stats.evictions++;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
/**
|
|
338
|
-
* Invalidate a specific cache entry or all entries
|
|
339
|
-
*
|
|
340
|
-
* @param key - Optional key to invalidate. If omitted, invalidates all entries
|
|
341
|
-
*/
|
|
342
|
-
invalidate(key) {
|
|
343
|
-
if (key) {
|
|
344
|
-
this.cache.delete(key);
|
|
345
|
-
} else {
|
|
346
|
-
this.cache.clear();
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
/**
|
|
350
|
-
* Clear all cache entries and reset statistics
|
|
351
|
-
*/
|
|
352
|
-
clear() {
|
|
353
|
-
this.cache.clear();
|
|
354
|
-
this.inflight.clear();
|
|
355
|
-
this.stats = {
|
|
356
|
-
hits: 0,
|
|
357
|
-
misses: 0,
|
|
358
|
-
evictions: 0,
|
|
359
|
-
coalesced: 0
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
/**
|
|
363
|
-
* Get cache statistics
|
|
364
|
-
*
|
|
365
|
-
* @returns Cache statistics including hit rate
|
|
366
|
-
*/
|
|
367
|
-
getStats() {
|
|
368
|
-
const total = this.stats.hits + this.stats.misses;
|
|
369
|
-
const hitRate = total > 0 ? this.stats.hits / total : 0;
|
|
370
|
-
return {
|
|
371
|
-
size: this.cache.size,
|
|
372
|
-
maxSize: this.maxSize,
|
|
373
|
-
hits: this.stats.hits,
|
|
374
|
-
misses: this.stats.misses,
|
|
375
|
-
evictions: this.stats.evictions,
|
|
376
|
-
coalesced: this.stats.coalesced,
|
|
377
|
-
hitRate
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
|
-
/**
|
|
381
|
-
* Get a cached value synchronously without triggering a fetch.
|
|
382
|
-
* Returns undefined if not in cache or expired.
|
|
383
|
-
*/
|
|
384
|
-
getCachedSync(key) {
|
|
385
|
-
const entry = this.cache.get(key);
|
|
386
|
-
if (!entry) return void 0;
|
|
387
|
-
if (this.ttl > 0 && Date.now() - entry.timestamp > this.ttl) {
|
|
388
|
-
this.cache.delete(key);
|
|
389
|
-
return void 0;
|
|
390
|
-
}
|
|
391
|
-
this.cache.delete(key);
|
|
392
|
-
this.cache.set(key, entry);
|
|
393
|
-
this.stats.hits++;
|
|
394
|
-
return entry.data;
|
|
395
|
-
}
|
|
396
|
-
/**
|
|
397
|
-
* Check if a key exists in the cache (and is not expired)
|
|
398
|
-
*
|
|
399
|
-
* @param key - Cache key to check
|
|
400
|
-
* @returns true if the key exists and is not expired
|
|
401
|
-
*/
|
|
402
|
-
has(key) {
|
|
403
|
-
const cached = this.cache.get(key);
|
|
404
|
-
if (!cached) {
|
|
405
|
-
return false;
|
|
406
|
-
}
|
|
407
|
-
const age = Date.now() - cached.timestamp;
|
|
408
|
-
if (age >= this.ttl) {
|
|
409
|
-
this.cache.delete(key);
|
|
410
|
-
return false;
|
|
411
|
-
}
|
|
412
|
-
return true;
|
|
413
|
-
}
|
|
414
|
-
};
|
|
415
|
-
|
|
416
72
|
// src/metadata-client.ts
|
|
73
|
+
function readSaveAdvisories(body) {
|
|
74
|
+
if (!body || typeof body !== "object") return [];
|
|
75
|
+
const raw = body.advisories;
|
|
76
|
+
if (!Array.isArray(raw)) return [];
|
|
77
|
+
return raw.filter((f) => {
|
|
78
|
+
if (!f || typeof f !== "object") return false;
|
|
79
|
+
const c = f;
|
|
80
|
+
return typeof c.rule === "string" && typeof c.path === "string" && typeof c.where === "string" && typeof c.message === "string" && typeof c.hint === "string" && typeof c.severity === "string";
|
|
81
|
+
});
|
|
82
|
+
}
|
|
417
83
|
var META_PREFIX = "/meta";
|
|
418
84
|
var API_PREFIX = "/api/v1";
|
|
419
85
|
function buildBase(config) {
|
|
@@ -446,6 +112,11 @@ async function parseError(res) {
|
|
|
446
112
|
if (code != null) err.code = String(code);
|
|
447
113
|
return err;
|
|
448
114
|
}
|
|
115
|
+
function isMetaItemEnvelope(value) {
|
|
116
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
117
|
+
const env = value;
|
|
118
|
+
return typeof env.type === "string" && typeof env.name === "string" && "item" in env;
|
|
119
|
+
}
|
|
449
120
|
var MetadataClient = class _MetadataClient {
|
|
450
121
|
constructor(config) {
|
|
451
122
|
__publicField(this, "base");
|
|
@@ -453,10 +124,13 @@ var MetadataClient = class _MetadataClient {
|
|
|
453
124
|
__publicField(this, "headers");
|
|
454
125
|
/** ADR-0037: when true, reads render the draft-overlaid world. */
|
|
455
126
|
__publicField(this, "previewDrafts");
|
|
127
|
+
/** #4133 — sink for post-save advisory findings; see the config field. */
|
|
128
|
+
__publicField(this, "onSaveAdvisory");
|
|
456
129
|
this.base = buildBase(config);
|
|
457
130
|
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
458
131
|
this.headers = { Accept: "application/json", ...config.headers ?? {} };
|
|
459
132
|
this.previewDrafts = config.previewDrafts === true;
|
|
133
|
+
this.onSaveAdvisory = config.onSaveAdvisory;
|
|
460
134
|
}
|
|
461
135
|
/** Update the client's environment scope at runtime. */
|
|
462
136
|
withEnvironment(environmentId) {
|
|
@@ -465,7 +139,12 @@ var MetadataClient = class _MetadataClient {
|
|
|
465
139
|
environmentId,
|
|
466
140
|
fetch: this.fetchImpl,
|
|
467
141
|
headers: this.headers,
|
|
468
|
-
previewDrafts: this.previewDrafts
|
|
142
|
+
previewDrafts: this.previewDrafts,
|
|
143
|
+
// #4133: the advisory sink must survive the clone. `useMetadataClient`
|
|
144
|
+
// routes EVERY environment-scoped console client through here, so
|
|
145
|
+
// dropping it would silently disable the channel for exactly the
|
|
146
|
+
// multi-environment tenants the gate is loudest for.
|
|
147
|
+
...this.onSaveAdvisory ? { onSaveAdvisory: this.onSaveAdvisory } : {}
|
|
469
148
|
});
|
|
470
149
|
}
|
|
471
150
|
/**
|
|
@@ -481,7 +160,11 @@ var MetadataClient = class _MetadataClient {
|
|
|
481
160
|
...envMatch ? { environmentId: decodeURIComponent(envMatch[1]) } : {},
|
|
482
161
|
fetch: this.fetchImpl,
|
|
483
162
|
headers: this.headers,
|
|
484
|
-
previewDrafts
|
|
163
|
+
previewDrafts,
|
|
164
|
+
// #4133: carried for the same reason as in `withEnvironment` — preview
|
|
165
|
+
// mode swaps the whole renderer tree's client, and writes are unaffected
|
|
166
|
+
// by ADR-0037, so the save door behind this clone still advises.
|
|
167
|
+
...this.onSaveAdvisory ? { onSaveAdvisory: this.onSaveAdvisory } : {}
|
|
485
168
|
});
|
|
486
169
|
}
|
|
487
170
|
/** List all registered metadata types (returns the registry rows). */
|
|
@@ -534,290 +217,696 @@ var MetadataClient = class _MetadataClient {
|
|
|
534
217
|
return data?.drafts ?? data?.data?.drafts ?? [];
|
|
535
218
|
}
|
|
536
219
|
/**
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
220
|
+
* Fetch a single metadata item and return the response body EXACTLY as the
|
|
221
|
+
* server sent it — envelope and all.
|
|
222
|
+
*
|
|
223
|
+
* The one transport both {@link get} (which unwraps) and {@link getDraft}
|
|
224
|
+
* (which does not) sit on, so the two can differ in what they hand back
|
|
225
|
+
* without differing in how they ask. Private on purpose: the envelope is a
|
|
226
|
+
* wire detail, and the two published contracts above are the supported ways
|
|
227
|
+
* to read an item.
|
|
228
|
+
*/
|
|
229
|
+
async readItemResponse(type, name, options = {}) {
|
|
230
|
+
const params = [];
|
|
231
|
+
if (options.state === "draft") params.push("state=draft");
|
|
232
|
+
else if (this.previewDrafts) params.push("preview=draft");
|
|
233
|
+
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
234
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
235
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
236
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
237
|
+
if (res.status === 404) return null;
|
|
238
|
+
if (!res.ok) throw await parseError(res);
|
|
239
|
+
return await res.json();
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Get a single metadata item. Returns the unwrapped item CONTENT — the
|
|
243
|
+
* metadata document itself, so `obj.fields` / `obj.label` are reachable
|
|
244
|
+
* directly.
|
|
245
|
+
*
|
|
246
|
+
* The framework answers `GET /meta/:type/:name` with the spec-declared
|
|
247
|
+
* envelope `{ type, name, item, …protection fields }`
|
|
248
|
+
* (`GetMetaItemResponseSchema`; objectstack#5563 collapsed this read to that
|
|
249
|
+
* ONE shape), so the envelope is unwrapped here — at the client boundary,
|
|
250
|
+
* once — rather than by each caller.
|
|
251
|
+
*
|
|
252
|
+
* **This method used to return the envelope while promising the body**
|
|
253
|
+
* (objectui#4271). Nothing detected the disagreement, because the test
|
|
254
|
+
* doubles across the repo were written against this docblock: every consumer
|
|
255
|
+
* reading `obj.fields` got `undefined` in production and a field list in
|
|
256
|
+
* unit tests. The visible cost was the entire field half of the permission
|
|
257
|
+
* matrix reporting "No fields registered for this object." for every object,
|
|
258
|
+
* plus dead RLS CEL autocomplete, an inert report drill-down and a designer
|
|
259
|
+
* that saved the envelope back over the object body.
|
|
260
|
+
*
|
|
261
|
+
* A response that is NOT the envelope (an older server answering the bare
|
|
262
|
+
* document, or a body of its own shape) passes through untouched, and `null`
|
|
263
|
+
* still comes back on 404 to keep the call site ergonomic.
|
|
264
|
+
*/
|
|
265
|
+
async get(type, name, options = {}) {
|
|
266
|
+
const resp = await this.readItemResponse(type, name, options);
|
|
267
|
+
if (isMetaItemEnvelope(resp)) return resp.item ?? null;
|
|
268
|
+
return resp;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Read the pending draft body for an item (`?state=draft`). Returns
|
|
272
|
+
* `null` when there is no draft pending. Draft reads do NOT fall
|
|
273
|
+
* back to the published overlay or the artifact registry — a `null`
|
|
274
|
+
* unambiguously means "nothing to publish".
|
|
275
|
+
*
|
|
276
|
+
* Note: this method hands back the `{ type, name, item }` envelope the
|
|
277
|
+
* framework sends, NOT the body — callers read `.item`. That asymmetry with
|
|
278
|
+
* {@link get} is deliberate and long-standing (the draft envelope's identity
|
|
279
|
+
* and protection carriers are part of what a draft reader inspects), so it
|
|
280
|
+
* is preserved by reading the transport directly instead of going through
|
|
281
|
+
* `get()`'s unwrap. `unwrapDraftBody` (app-shell) and `unwrapViewDraft`
|
|
282
|
+
* (this package) are the shared helpers for taking the body out.
|
|
283
|
+
*/
|
|
284
|
+
async getDraft(type, name, options = {}) {
|
|
285
|
+
return this.readItemResponse(type, name, {
|
|
286
|
+
state: "draft",
|
|
287
|
+
...options.packageId ? { packageId: options.packageId } : {}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Save (PUT) a metadata item. The framework accepts both the bare
|
|
292
|
+
* item payload and the `{ item: ... }` / `{ metadata: ... }`
|
|
293
|
+
* envelopes; we send bare for consistency. Pass `mode: 'draft'` to
|
|
294
|
+
* stage the change without publishing (Studio's "Save" button).
|
|
295
|
+
*/
|
|
296
|
+
async save(type, name, item, options = {}) {
|
|
297
|
+
if (!name || !String(name).trim()) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const params = [];
|
|
303
|
+
if (options.force) params.push("force=true");
|
|
304
|
+
if (options.mode === "draft") params.push("mode=draft");
|
|
305
|
+
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
306
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
307
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
308
|
+
const headers = {
|
|
309
|
+
...this.headers,
|
|
310
|
+
"Content-Type": "application/json"
|
|
311
|
+
};
|
|
312
|
+
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
313
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
314
|
+
const res = await this.fetchImpl(url, {
|
|
315
|
+
method: "PUT",
|
|
316
|
+
headers,
|
|
317
|
+
body: JSON.stringify(item)
|
|
318
|
+
});
|
|
319
|
+
if (!res.ok) throw await parseError(res);
|
|
320
|
+
const body = await res.json();
|
|
321
|
+
if (this.onSaveAdvisory) {
|
|
322
|
+
try {
|
|
323
|
+
const advisories = readSaveAdvisories(body);
|
|
324
|
+
if (advisories.length > 0) {
|
|
325
|
+
this.onSaveAdvisory({
|
|
326
|
+
type,
|
|
327
|
+
name,
|
|
328
|
+
mode: options.mode === "draft" ? "draft" : "publish",
|
|
329
|
+
advisories
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return body;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Publish a single pending draft BY REFERENCE — promotes the draft row for
|
|
339
|
+
* `(type, name)` into the active overlay and drops the draft. Works for ANY
|
|
340
|
+
* draft, including ones with no `packageId` binding (which the package-scoped
|
|
341
|
+
* `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
|
|
342
|
+
* exact set returned by {@link listDrafts} without needing a package.
|
|
343
|
+
*
|
|
344
|
+
* Returns the server result. For `seed` drafts the protocol also materializes
|
|
345
|
+
* the rows and reports under `seedApplied` — a data problem never fails the
|
|
346
|
+
* publish, so callers should check `seedApplied?.success` and warn the user
|
|
347
|
+
* rather than assume the data went live.
|
|
348
|
+
*/
|
|
349
|
+
async publishDraft(type, name) {
|
|
350
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
|
|
351
|
+
const res = await this.fetchImpl(url, {
|
|
352
|
+
method: "POST",
|
|
353
|
+
headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
|
|
354
|
+
body: "{}"
|
|
355
|
+
});
|
|
356
|
+
if (!res.ok) throw await parseError(res);
|
|
357
|
+
const body = await res.json().catch(() => ({}));
|
|
358
|
+
const inner = body?.data && typeof body.data === "object" ? body.data : body;
|
|
359
|
+
return inner;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Get the 3-state layered view of a metadata item (Phase 3a). Returns
|
|
363
|
+
* `code` (the artifact / fallback default), `overlay` (the saved
|
|
364
|
+
* customisation, if any), and `effective` (what the runtime sees).
|
|
365
|
+
*/
|
|
366
|
+
async layered(type, name, options = {}) {
|
|
367
|
+
const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : "";
|
|
368
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
|
|
369
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
370
|
+
if (res.status === 404) {
|
|
371
|
+
return { code: null, overlay: null, overlayScope: null, effective: null };
|
|
372
|
+
}
|
|
373
|
+
if (!res.ok) throw await parseError(res);
|
|
374
|
+
const body = await res.json();
|
|
375
|
+
const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
|
|
376
|
+
if (!hasEnvelope) {
|
|
377
|
+
return {
|
|
378
|
+
code: null,
|
|
379
|
+
overlay: null,
|
|
380
|
+
overlayScope: null,
|
|
381
|
+
effective: body
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
code: body.code ?? null,
|
|
386
|
+
overlay: body.overlay ?? null,
|
|
387
|
+
overlayScope: body.overlayScope ?? null,
|
|
388
|
+
effective: body.effective ?? null,
|
|
389
|
+
...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
|
|
390
|
+
// ADR-0010 Phase 4 — pass through the metadata-protection envelope so
|
|
391
|
+
// the editor can render lock affordances without a second round trip.
|
|
392
|
+
...body.lock !== void 0 ? { lock: body.lock } : {},
|
|
393
|
+
...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
|
|
394
|
+
...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
|
|
395
|
+
...body.lockDocsUrl !== void 0 ? { lockDocsUrl: body.lockDocsUrl } : {},
|
|
396
|
+
...body.provenance !== void 0 ? { provenance: body.provenance } : {},
|
|
397
|
+
...body.packageId !== void 0 ? { packageId: body.packageId } : {},
|
|
398
|
+
...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
|
|
399
|
+
...body.editable !== void 0 ? { editable: body.editable } : {},
|
|
400
|
+
...body.deletable !== void 0 ? { deletable: body.deletable } : {},
|
|
401
|
+
...body.resettable !== void 0 ? { resettable: body.resettable } : {}
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Cross-type sweep of load-time validation results — calls
|
|
406
|
+
* `GET /meta/diagnostics`. Returns every entry the framework
|
|
407
|
+
* considers invalid (or, when `severity: 'warning'`, also entries
|
|
408
|
+
* with only warnings).
|
|
409
|
+
*
|
|
410
|
+
* Used by the Studio's governance overview page and by the
|
|
411
|
+
* directory page to show "N invalid" badges per metadata type.
|
|
412
|
+
*/
|
|
413
|
+
async diagnostics(options = {}) {
|
|
414
|
+
const params = [];
|
|
415
|
+
if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
|
|
416
|
+
if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
|
|
417
|
+
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
418
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
419
|
+
const url = `${this.base}/diagnostics${qs}`;
|
|
420
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
421
|
+
if (res.status === 404) {
|
|
422
|
+
return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
|
|
423
|
+
}
|
|
424
|
+
if (!res.ok) throw await parseError(res);
|
|
425
|
+
const data = await res.json();
|
|
426
|
+
return {
|
|
427
|
+
entries: Array.isArray(data.entries) ? data.entries : [],
|
|
428
|
+
total: typeof data.total === "number" ? data.total : 0,
|
|
429
|
+
scannedTypes: typeof data.scannedTypes === "number" ? data.scannedTypes : 0,
|
|
430
|
+
scannedItems: typeof data.scannedItems === "number" ? data.scannedItems : 0,
|
|
431
|
+
stats: data.stats && typeof data.stats === "object" ? data.stats : {}
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Find every metadata item that references this one (Phase 3a). Useful
|
|
436
|
+
* for pre-delete impact analysis: "Are any views pointing at this
|
|
437
|
+
* object before I drop it?".
|
|
438
|
+
*/
|
|
439
|
+
async references(type, name) {
|
|
440
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
|
|
441
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
442
|
+
if (res.status === 404) return [];
|
|
443
|
+
if (!res.ok) throw await parseError(res);
|
|
444
|
+
const data = await res.json();
|
|
445
|
+
return Array.isArray(data) ? data : data?.items ?? [];
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Reset a metadata customization overlay back to the artifact default.
|
|
449
|
+
* Idempotent: returns the result even when no overlay row existed.
|
|
450
|
+
* Pass `state: 'draft'` to discard the pending draft only (keeps the
|
|
451
|
+
* published overlay intact) — useful for a Studio "Discard draft" button.
|
|
540
452
|
*/
|
|
541
|
-
async
|
|
542
|
-
const
|
|
543
|
-
if (options.state === "draft") params.push("state=draft");
|
|
544
|
-
else if (this.previewDrafts) params.push("preview=draft");
|
|
545
|
-
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
546
|
-
const qs = params.length ? `?${params.join("&")}` : "";
|
|
453
|
+
async reset(type, name, options = {}) {
|
|
454
|
+
const qs = options.state === "draft" ? "?state=draft" : "";
|
|
547
455
|
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
548
|
-
const
|
|
549
|
-
if (
|
|
456
|
+
const headers = { ...this.headers };
|
|
457
|
+
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
458
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
459
|
+
const res = await this.fetchImpl(url, { method: "DELETE", headers });
|
|
550
460
|
if (!res.ok) throw await parseError(res);
|
|
551
461
|
return await res.json();
|
|
552
462
|
}
|
|
553
463
|
/**
|
|
554
|
-
*
|
|
555
|
-
* `
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
* Note: the framework wraps draft responses in an envelope
|
|
560
|
-
* `{ type, name, item }` (matching `getMetaItem`); callers should
|
|
561
|
-
* read `.item` to get the body. The legacy `get()` returns the
|
|
562
|
-
* unwrapped body, so this method preserves that asymmetry by
|
|
563
|
-
* returning whatever the server sent.
|
|
464
|
+
* Promote the pending draft of an item to the active overlay
|
|
465
|
+
* (POST `/meta/:type/:name/publish`). Returns
|
|
466
|
+
* `{ success, version, seq, message }`. Throws a `404 no_draft` if
|
|
467
|
+
* nothing is pending and `409 metadata_conflict` if the published
|
|
468
|
+
* overlay moved while the draft was sitting.
|
|
564
469
|
*/
|
|
565
|
-
async
|
|
566
|
-
|
|
470
|
+
async publish(type, name, options = {}) {
|
|
471
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
|
|
472
|
+
const headers = {
|
|
473
|
+
...this.headers,
|
|
474
|
+
"Content-Type": "application/json"
|
|
475
|
+
};
|
|
476
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
477
|
+
const res = await this.fetchImpl(url, {
|
|
478
|
+
method: "POST",
|
|
479
|
+
headers,
|
|
480
|
+
body: JSON.stringify(options.message ? { message: options.message } : {})
|
|
481
|
+
});
|
|
482
|
+
if (!res.ok) throw await parseError(res);
|
|
483
|
+
return await res.json();
|
|
567
484
|
}
|
|
568
485
|
/**
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
486
|
+
* Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
|
|
487
|
+
* The server reads the history row at `toVersion`, writes its body back
|
|
488
|
+
* as the active overlay with `operation_type='revert'`. Returns
|
|
489
|
+
* `{ success, version, seq, restoredFromVersion, message }`. Throws
|
|
490
|
+
* `404 version_not_found` for unknown versions and `409 version_not_restorable`
|
|
491
|
+
* when the target version is a delete tombstone.
|
|
573
492
|
*/
|
|
574
|
-
async
|
|
575
|
-
|
|
576
|
-
throw new Error(
|
|
577
|
-
`MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
|
|
578
|
-
);
|
|
579
|
-
}
|
|
580
|
-
const params = [];
|
|
581
|
-
if (options.force) params.push("force=true");
|
|
582
|
-
if (options.mode === "draft") params.push("mode=draft");
|
|
583
|
-
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
584
|
-
const qs = params.length ? `?${params.join("&")}` : "";
|
|
585
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
493
|
+
async rollback(type, name, toVersion, options = {}) {
|
|
494
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
|
|
586
495
|
const headers = {
|
|
587
496
|
...this.headers,
|
|
588
497
|
"Content-Type": "application/json"
|
|
589
498
|
};
|
|
590
|
-
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
591
499
|
if (options.actor) headers["X-Actor"] = options.actor;
|
|
592
500
|
const res = await this.fetchImpl(url, {
|
|
593
|
-
method: "
|
|
501
|
+
method: "POST",
|
|
594
502
|
headers,
|
|
595
|
-
body: JSON.stringify(
|
|
503
|
+
body: JSON.stringify({
|
|
504
|
+
toVersion,
|
|
505
|
+
...options.message ? { message: options.message } : {}
|
|
506
|
+
})
|
|
507
|
+
});
|
|
508
|
+
if (!res.ok) throw await parseError(res);
|
|
509
|
+
return await res.json();
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Compute a structured top-level key diff between two history versions
|
|
513
|
+
* (GET `/meta/:type/:name/diff?from=&to=`). Returns
|
|
514
|
+
* `{ added, removed, changed }` where each entry carries a `path` and
|
|
515
|
+
* the relevant value(s). Useful for "what changed in this draft?" and
|
|
516
|
+
* pre-rollback previews.
|
|
517
|
+
*/
|
|
518
|
+
async diff(type, name, fromVersion, toVersion) {
|
|
519
|
+
const params = new URLSearchParams();
|
|
520
|
+
if (fromVersion !== void 0) params.set("from", String(fromVersion));
|
|
521
|
+
if (toVersion !== void 0) params.set("to", String(toVersion));
|
|
522
|
+
const qs = params.toString();
|
|
523
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ""}`;
|
|
524
|
+
const res = await this.fetchImpl(url, {
|
|
525
|
+
method: "GET",
|
|
526
|
+
headers: this.headers,
|
|
527
|
+
cache: "no-store"
|
|
596
528
|
});
|
|
597
529
|
if (!res.ok) throw await parseError(res);
|
|
598
530
|
return await res.json();
|
|
599
531
|
}
|
|
532
|
+
/** Fetch the durable history (change log) for one metadata item. */
|
|
533
|
+
async history(type, name, options = {}) {
|
|
534
|
+
const params = new URLSearchParams();
|
|
535
|
+
if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
|
|
536
|
+
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
537
|
+
const qs = params.toString();
|
|
538
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
|
|
539
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
540
|
+
if (!res.ok) throw await parseError(res);
|
|
541
|
+
return await res.json();
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Fetch the protection-audit trail for one metadata item
|
|
545
|
+
* (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
|
|
546
|
+
* delete/reset attempt — allowed, denied, or forced — so the
|
|
547
|
+
* Studio "审计日志 / Audit log" tab can render who tried what
|
|
548
|
+
* and whether a lock blocked it.
|
|
549
|
+
*/
|
|
550
|
+
async audit(type, name, options = {}) {
|
|
551
|
+
const params = new URLSearchParams();
|
|
552
|
+
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
553
|
+
const qs = params.toString();
|
|
554
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ""}`;
|
|
555
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
556
|
+
if (!res.ok) throw await parseError(res);
|
|
557
|
+
return await res.json();
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// ../types/src/index.ts
|
|
562
|
+
var import_spec = require("@objectstack/spec");
|
|
563
|
+
|
|
564
|
+
// ../types/src/error-code.ts
|
|
565
|
+
function errorCodeIs(err, code) {
|
|
566
|
+
const raw = err?.code;
|
|
567
|
+
if (typeof raw !== "string" || raw.length === 0) return false;
|
|
568
|
+
return raw.toUpperCase() === code.toUpperCase();
|
|
569
|
+
}
|
|
570
|
+
function errorCodeIsAnyOf(err, codes) {
|
|
571
|
+
return codes.some((c) => errorCodeIs(err, c));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ../core/src/utils/filter-converter.ts
|
|
575
|
+
var import_ui = require("@objectstack/spec/ui");
|
|
576
|
+
var FilterOperatorError = class extends Error {
|
|
577
|
+
constructor(message) {
|
|
578
|
+
super(message);
|
|
579
|
+
__publicField(this, "code", "INVALID_FILTER");
|
|
580
|
+
__publicField(this, "httpStatus", 400);
|
|
581
|
+
this.name = "FilterOperatorError";
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
function convertOperatorToAST(operator) {
|
|
585
|
+
const operatorMap = {
|
|
586
|
+
"$eq": "=",
|
|
587
|
+
"$ne": "!=",
|
|
588
|
+
"$gt": ">",
|
|
589
|
+
"$gte": ">=",
|
|
590
|
+
"$lt": "<",
|
|
591
|
+
"$lte": "<=",
|
|
592
|
+
"$in": "in",
|
|
593
|
+
"$nin": "nin",
|
|
594
|
+
"$notin": "nin",
|
|
595
|
+
"$between": "between",
|
|
596
|
+
"$contains": "contains",
|
|
597
|
+
"$notContains": "notcontains",
|
|
598
|
+
"$notcontains": "notcontains",
|
|
599
|
+
"$startsWith": "startswith",
|
|
600
|
+
"$startswith": "startswith",
|
|
601
|
+
"$endsWith": "endswith",
|
|
602
|
+
"$endswith": "endswith"
|
|
603
|
+
};
|
|
604
|
+
return operatorMap[operator] || null;
|
|
605
|
+
}
|
|
606
|
+
function convertFiltersToAST(filter) {
|
|
607
|
+
const conditions = [];
|
|
608
|
+
for (const [field, value] of Object.entries(filter)) {
|
|
609
|
+
if (value === null || value === void 0) continue;
|
|
610
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
611
|
+
for (const [operator, operatorValue] of Object.entries(value)) {
|
|
612
|
+
if (operator === "$regex") {
|
|
613
|
+
throw new FilterOperatorError(
|
|
614
|
+
`[ObjectUI] The '$regex' filter operator is not supported. It used to be converted to 'contains', which matches a literal substring rather than a pattern \u2014 a different result, not a degraded one. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. Use $contains, $startsWith or $endsWith.`
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
if (operator === "$null") {
|
|
618
|
+
conditions.push([field, operatorValue ? "is_null" : "is_not_null", true]);
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (operator === "$exists") {
|
|
622
|
+
conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
const astOperator = convertOperatorToAST(operator);
|
|
626
|
+
if (astOperator) {
|
|
627
|
+
conditions.push([field, astOperator, operatorValue]);
|
|
628
|
+
} else {
|
|
629
|
+
throw new FilterOperatorError(
|
|
630
|
+
`[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, $notContains, $startsWith, $endsWith, $null, $exists. If you need exact object matching, use the value directly without an operator.`
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
} else {
|
|
635
|
+
conditions.push([field, "=", value]);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
if (conditions.length === 0) {
|
|
639
|
+
return filter;
|
|
640
|
+
}
|
|
641
|
+
if (conditions.length === 1) {
|
|
642
|
+
return conditions[0];
|
|
643
|
+
}
|
|
644
|
+
return ["and", ...conditions];
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ../core/src/adapters/batchTransaction.ts
|
|
648
|
+
function idOf(rec) {
|
|
649
|
+
if (rec == null) return void 0;
|
|
650
|
+
return rec.id ?? rec._id ?? rec.recordId;
|
|
651
|
+
}
|
|
652
|
+
function resolveRefs(data, results) {
|
|
653
|
+
const out = {};
|
|
654
|
+
for (const [key, value] of Object.entries(data)) {
|
|
655
|
+
if (value && typeof value === "object" && !Array.isArray(value) && typeof value.$ref === "number") {
|
|
656
|
+
const refIndex = value.$ref;
|
|
657
|
+
const id = idOf(results[refIndex]);
|
|
658
|
+
if (id === void 0) {
|
|
659
|
+
throw new Error(
|
|
660
|
+
`batchTransaction: field "${key}" references operation ${refIndex}, which has not produced an id (forward or invalid $ref)`
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
out[key] = id;
|
|
664
|
+
} else {
|
|
665
|
+
out[key] = value;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return out;
|
|
669
|
+
}
|
|
670
|
+
async function emulateBatchTransaction(dataSource, operations) {
|
|
671
|
+
const results = [];
|
|
672
|
+
const created = [];
|
|
673
|
+
try {
|
|
674
|
+
for (const op of operations) {
|
|
675
|
+
const action = op.action ?? "create";
|
|
676
|
+
if (action === "create") {
|
|
677
|
+
const record = await dataSource.create(
|
|
678
|
+
op.object,
|
|
679
|
+
resolveRefs(op.data ?? {}, results)
|
|
680
|
+
);
|
|
681
|
+
results.push(record);
|
|
682
|
+
const id = idOf(record);
|
|
683
|
+
if (id) created.push({ object: op.object, id });
|
|
684
|
+
} else if (action === "update") {
|
|
685
|
+
if (op.id == null) {
|
|
686
|
+
throw new Error(`batchTransaction: update on "${op.object}" requires an id`);
|
|
687
|
+
}
|
|
688
|
+
results.push(
|
|
689
|
+
await dataSource.update(
|
|
690
|
+
op.object,
|
|
691
|
+
op.id,
|
|
692
|
+
resolveRefs(op.data ?? {}, results)
|
|
693
|
+
)
|
|
694
|
+
);
|
|
695
|
+
} else {
|
|
696
|
+
if (op.id == null) {
|
|
697
|
+
throw new Error(`batchTransaction: delete on "${op.object}" requires an id`);
|
|
698
|
+
}
|
|
699
|
+
results.push(await dataSource.delete(op.object, op.id));
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return { results };
|
|
703
|
+
} catch (err) {
|
|
704
|
+
if (created.length > 0) {
|
|
705
|
+
await Promise.allSettled(
|
|
706
|
+
[...created].reverse().map((c) => dataSource.delete(c.object, c.id))
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
throw err;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ../core/src/utils/reference-keys.ts
|
|
714
|
+
function normalizeFieldReferenceKeys(fieldDef) {
|
|
715
|
+
if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
|
|
716
|
+
const f = fieldDef;
|
|
717
|
+
const target = f.reference_to ?? f.reference ?? f.referenceTo;
|
|
718
|
+
if (target == null || target === "") return fieldDef;
|
|
719
|
+
if (f.reference_to === void 0) f.reference_to = target;
|
|
720
|
+
if (f.reference === void 0) f.reference = target;
|
|
721
|
+
return fieldDef;
|
|
722
|
+
}
|
|
723
|
+
function normalizeSchemaReferenceKeys(schema) {
|
|
724
|
+
const fields = schema && typeof schema === "object" ? schema.fields : null;
|
|
725
|
+
if (!fields || typeof fields !== "object") return schema;
|
|
726
|
+
const defs = Array.isArray(fields) ? fields : Object.values(fields);
|
|
727
|
+
for (const def of defs) normalizeFieldReferenceKeys(def);
|
|
728
|
+
return schema;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// src/cache/MetadataCache.ts
|
|
732
|
+
var MetadataCache = class {
|
|
600
733
|
/**
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
* Returns the server result. For `seed` drafts the protocol also materializes
|
|
608
|
-
* the rows and reports under `seedApplied` — a data problem never fails the
|
|
609
|
-
* publish, so callers should check `seedApplied?.success` and warn the user
|
|
610
|
-
* rather than assume the data went live.
|
|
734
|
+
* Create a new MetadataCache instance
|
|
735
|
+
*
|
|
736
|
+
* @param options - Configuration options
|
|
737
|
+
* @param options.maxSize - Maximum number of entries (default: 100)
|
|
738
|
+
* @param options.ttl - Time to live in milliseconds (default: 5 minutes)
|
|
611
739
|
*/
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
740
|
+
constructor(options = {}) {
|
|
741
|
+
__publicField(this, "cache");
|
|
742
|
+
__publicField(this, "inflight");
|
|
743
|
+
__publicField(this, "maxSize");
|
|
744
|
+
__publicField(this, "ttl");
|
|
745
|
+
__publicField(this, "stats");
|
|
746
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
747
|
+
this.inflight = /* @__PURE__ */ new Map();
|
|
748
|
+
this.maxSize = options.maxSize || 100;
|
|
749
|
+
this.ttl = options.ttl || 5 * 60 * 1e3;
|
|
750
|
+
this.stats = {
|
|
751
|
+
hits: 0,
|
|
752
|
+
misses: 0,
|
|
753
|
+
evictions: 0,
|
|
754
|
+
coalesced: 0
|
|
755
|
+
};
|
|
623
756
|
}
|
|
624
757
|
/**
|
|
625
|
-
* Get
|
|
626
|
-
*
|
|
627
|
-
*
|
|
758
|
+
* Get a value from cache or fetch it using the provided fetcher function
|
|
759
|
+
*
|
|
760
|
+
* @param key - Cache key
|
|
761
|
+
* @param fetcher - Async function to fetch data if not in cache
|
|
762
|
+
* @returns Promise resolving to the cached or fetched data
|
|
628
763
|
*/
|
|
629
|
-
async
|
|
630
|
-
const
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
764
|
+
async get(key, fetcher) {
|
|
765
|
+
const now = Date.now();
|
|
766
|
+
const cached = this.cache.get(key);
|
|
767
|
+
if (cached) {
|
|
768
|
+
const age = now - cached.timestamp;
|
|
769
|
+
if (age < this.ttl) {
|
|
770
|
+
cached.accessCount++;
|
|
771
|
+
cached.lastAccessed = now;
|
|
772
|
+
this.stats.hits++;
|
|
773
|
+
this.cache.delete(key);
|
|
774
|
+
this.cache.set(key, cached);
|
|
775
|
+
return cached.data;
|
|
776
|
+
} else {
|
|
777
|
+
this.cache.delete(key);
|
|
778
|
+
}
|
|
635
779
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
return {
|
|
641
|
-
code: null,
|
|
642
|
-
overlay: null,
|
|
643
|
-
overlayScope: null,
|
|
644
|
-
effective: body
|
|
645
|
-
};
|
|
780
|
+
const existing = this.inflight.get(key);
|
|
781
|
+
if (existing) {
|
|
782
|
+
this.stats.coalesced++;
|
|
783
|
+
return existing;
|
|
646
784
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
...body.provenance !== void 0 ? { provenance: body.provenance } : {},
|
|
660
|
-
...body.packageId !== void 0 ? { packageId: body.packageId } : {},
|
|
661
|
-
...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
|
|
662
|
-
...body.editable !== void 0 ? { editable: body.editable } : {},
|
|
663
|
-
...body.deletable !== void 0 ? { deletable: body.deletable } : {},
|
|
664
|
-
...body.resettable !== void 0 ? { resettable: body.resettable } : {}
|
|
665
|
-
};
|
|
785
|
+
this.stats.misses++;
|
|
786
|
+
const promise = (async () => {
|
|
787
|
+
try {
|
|
788
|
+
const data = await fetcher();
|
|
789
|
+
this.set(key, data);
|
|
790
|
+
return data;
|
|
791
|
+
} finally {
|
|
792
|
+
this.inflight.delete(key);
|
|
793
|
+
}
|
|
794
|
+
})();
|
|
795
|
+
this.inflight.set(key, promise);
|
|
796
|
+
return promise;
|
|
666
797
|
}
|
|
667
798
|
/**
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
* with only warnings).
|
|
672
|
-
*
|
|
673
|
-
* Used by the Studio's governance overview page and by the
|
|
674
|
-
* directory page to show "N invalid" badges per metadata type.
|
|
799
|
+
* Prime the cache with a pre-fetched value. Useful when a bulk endpoint
|
|
800
|
+
* (e.g. list of all object schemas) returns data that would otherwise
|
|
801
|
+
* be fetched again per item.
|
|
675
802
|
*/
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
|
|
679
|
-
if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
|
|
680
|
-
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
681
|
-
const qs = params.length ? `?${params.join("&")}` : "";
|
|
682
|
-
const url = `${this.base}/diagnostics${qs}`;
|
|
683
|
-
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
684
|
-
if (res.status === 404) {
|
|
685
|
-
return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
|
|
686
|
-
}
|
|
687
|
-
if (!res.ok) throw await parseError(res);
|
|
688
|
-
const data = await res.json();
|
|
689
|
-
return {
|
|
690
|
-
entries: Array.isArray(data.entries) ? data.entries : [],
|
|
691
|
-
total: typeof data.total === "number" ? data.total : 0,
|
|
692
|
-
scannedTypes: typeof data.scannedTypes === "number" ? data.scannedTypes : 0,
|
|
693
|
-
scannedItems: typeof data.scannedItems === "number" ? data.scannedItems : 0,
|
|
694
|
-
stats: data.stats && typeof data.stats === "object" ? data.stats : {}
|
|
695
|
-
};
|
|
803
|
+
prime(key, data) {
|
|
804
|
+
this.set(key, data);
|
|
696
805
|
}
|
|
697
806
|
/**
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
807
|
+
* Set a value in the cache
|
|
808
|
+
*
|
|
809
|
+
* @param key - Cache key
|
|
810
|
+
* @param data - Data to cache
|
|
701
811
|
*/
|
|
702
|
-
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
812
|
+
set(key, data) {
|
|
813
|
+
const now = Date.now();
|
|
814
|
+
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
|
|
815
|
+
this.evictLRU();
|
|
816
|
+
}
|
|
817
|
+
this.cache.set(key, {
|
|
818
|
+
data,
|
|
819
|
+
timestamp: now,
|
|
820
|
+
accessCount: 1,
|
|
821
|
+
lastAccessed: now
|
|
822
|
+
});
|
|
709
823
|
}
|
|
710
824
|
/**
|
|
711
|
-
*
|
|
712
|
-
* Idempotent: returns the result even when no overlay row existed.
|
|
713
|
-
* Pass `state: 'draft'` to discard the pending draft only (keeps the
|
|
714
|
-
* published overlay intact) — useful for a Studio "Discard draft" button.
|
|
825
|
+
* Evict the least recently used entry
|
|
715
826
|
*/
|
|
716
|
-
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
const res = await this.fetchImpl(url, { method: "DELETE", headers });
|
|
723
|
-
if (!res.ok) throw await parseError(res);
|
|
724
|
-
return await res.json();
|
|
827
|
+
evictLRU() {
|
|
828
|
+
const firstKey = this.cache.keys().next().value;
|
|
829
|
+
if (firstKey !== void 0) {
|
|
830
|
+
this.cache.delete(firstKey);
|
|
831
|
+
this.stats.evictions++;
|
|
832
|
+
}
|
|
725
833
|
}
|
|
726
834
|
/**
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
* nothing is pending and `409 metadata_conflict` if the published
|
|
731
|
-
* overlay moved while the draft was sitting.
|
|
835
|
+
* Invalidate a specific cache entry or all entries
|
|
836
|
+
*
|
|
837
|
+
* @param key - Optional key to invalidate. If omitted, invalidates all entries
|
|
732
838
|
*/
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
}
|
|
739
|
-
if (options.actor) headers["X-Actor"] = options.actor;
|
|
740
|
-
const res = await this.fetchImpl(url, {
|
|
741
|
-
method: "POST",
|
|
742
|
-
headers,
|
|
743
|
-
body: JSON.stringify(options.message ? { message: options.message } : {})
|
|
744
|
-
});
|
|
745
|
-
if (!res.ok) throw await parseError(res);
|
|
746
|
-
return await res.json();
|
|
839
|
+
invalidate(key) {
|
|
840
|
+
if (key) {
|
|
841
|
+
this.cache.delete(key);
|
|
842
|
+
} else {
|
|
843
|
+
this.cache.clear();
|
|
844
|
+
}
|
|
747
845
|
}
|
|
748
846
|
/**
|
|
749
|
-
*
|
|
750
|
-
* The server reads the history row at `toVersion`, writes its body back
|
|
751
|
-
* as the active overlay with `operation_type='revert'`. Returns
|
|
752
|
-
* `{ success, version, seq, restoredFromVersion, message }`. Throws
|
|
753
|
-
* `404 version_not_found` for unknown versions and `409 version_not_restorable`
|
|
754
|
-
* when the target version is a delete tombstone.
|
|
847
|
+
* Clear all cache entries and reset statistics
|
|
755
848
|
*/
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
849
|
+
clear() {
|
|
850
|
+
this.cache.clear();
|
|
851
|
+
this.inflight.clear();
|
|
852
|
+
this.stats = {
|
|
853
|
+
hits: 0,
|
|
854
|
+
misses: 0,
|
|
855
|
+
evictions: 0,
|
|
856
|
+
coalesced: 0
|
|
761
857
|
};
|
|
762
|
-
if (options.actor) headers["X-Actor"] = options.actor;
|
|
763
|
-
const res = await this.fetchImpl(url, {
|
|
764
|
-
method: "POST",
|
|
765
|
-
headers,
|
|
766
|
-
body: JSON.stringify({
|
|
767
|
-
toVersion,
|
|
768
|
-
...options.message ? { message: options.message } : {}
|
|
769
|
-
})
|
|
770
|
-
});
|
|
771
|
-
if (!res.ok) throw await parseError(res);
|
|
772
|
-
return await res.json();
|
|
773
858
|
}
|
|
774
859
|
/**
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
*
|
|
778
|
-
* the relevant value(s). Useful for "what changed in this draft?" and
|
|
779
|
-
* pre-rollback previews.
|
|
860
|
+
* Get cache statistics
|
|
861
|
+
*
|
|
862
|
+
* @returns Cache statistics including hit rate
|
|
780
863
|
*/
|
|
781
|
-
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
return await res.json();
|
|
864
|
+
getStats() {
|
|
865
|
+
const total = this.stats.hits + this.stats.misses;
|
|
866
|
+
const hitRate = total > 0 ? this.stats.hits / total : 0;
|
|
867
|
+
return {
|
|
868
|
+
size: this.cache.size,
|
|
869
|
+
maxSize: this.maxSize,
|
|
870
|
+
hits: this.stats.hits,
|
|
871
|
+
misses: this.stats.misses,
|
|
872
|
+
evictions: this.stats.evictions,
|
|
873
|
+
coalesced: this.stats.coalesced,
|
|
874
|
+
hitRate
|
|
875
|
+
};
|
|
794
876
|
}
|
|
795
|
-
/**
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
const
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
877
|
+
/**
|
|
878
|
+
* Get a cached value synchronously without triggering a fetch.
|
|
879
|
+
* Returns undefined if not in cache or expired.
|
|
880
|
+
*/
|
|
881
|
+
getCachedSync(key) {
|
|
882
|
+
const entry = this.cache.get(key);
|
|
883
|
+
if (!entry) return void 0;
|
|
884
|
+
if (this.ttl > 0 && Date.now() - entry.timestamp > this.ttl) {
|
|
885
|
+
this.cache.delete(key);
|
|
886
|
+
return void 0;
|
|
887
|
+
}
|
|
888
|
+
this.cache.delete(key);
|
|
889
|
+
this.cache.set(key, entry);
|
|
890
|
+
this.stats.hits++;
|
|
891
|
+
return entry.data;
|
|
805
892
|
}
|
|
806
893
|
/**
|
|
807
|
-
*
|
|
808
|
-
*
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
* and whether a lock blocked it.
|
|
894
|
+
* Check if a key exists in the cache (and is not expired)
|
|
895
|
+
*
|
|
896
|
+
* @param key - Cache key to check
|
|
897
|
+
* @returns true if the key exists and is not expired
|
|
812
898
|
*/
|
|
813
|
-
|
|
814
|
-
const
|
|
815
|
-
if (
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
const
|
|
819
|
-
if (
|
|
820
|
-
|
|
899
|
+
has(key) {
|
|
900
|
+
const cached = this.cache.get(key);
|
|
901
|
+
if (!cached) {
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
const age = Date.now() - cached.timestamp;
|
|
905
|
+
if (age >= this.ttl) {
|
|
906
|
+
this.cache.delete(key);
|
|
907
|
+
return false;
|
|
908
|
+
}
|
|
909
|
+
return true;
|
|
821
910
|
}
|
|
822
911
|
};
|
|
823
912
|
|
|
@@ -1008,45 +1097,6 @@ function isErrorType(error, errorClass) {
|
|
|
1008
1097
|
return error instanceof errorClass;
|
|
1009
1098
|
}
|
|
1010
1099
|
|
|
1011
|
-
// src/cloud.ts
|
|
1012
|
-
var CloudOperations = class {
|
|
1013
|
-
constructor(getClient) {
|
|
1014
|
-
__publicField(this, "getClient", getClient);
|
|
1015
|
-
}
|
|
1016
|
-
/**
|
|
1017
|
-
* Deploy an application to the cloud.
|
|
1018
|
-
*/
|
|
1019
|
-
async deploy(appId, config) {
|
|
1020
|
-
const client = this.getClient();
|
|
1021
|
-
const result = await client.cloud?.deploy?.(appId, config);
|
|
1022
|
-
return result ?? { deploymentId: `deploy-${Date.now()}`, status: "pending" };
|
|
1023
|
-
}
|
|
1024
|
-
/**
|
|
1025
|
-
* Get deployment status.
|
|
1026
|
-
*/
|
|
1027
|
-
async getDeploymentStatus(deploymentId) {
|
|
1028
|
-
const client = this.getClient();
|
|
1029
|
-
const result = await client.cloud?.getDeployment?.(deploymentId);
|
|
1030
|
-
return result ?? { status: "unknown" };
|
|
1031
|
-
}
|
|
1032
|
-
/**
|
|
1033
|
-
* Search marketplace entries.
|
|
1034
|
-
*/
|
|
1035
|
-
async searchMarketplace(query, category) {
|
|
1036
|
-
const client = this.getClient();
|
|
1037
|
-
const result = await client.cloud?.marketplace?.search?.({ query, category });
|
|
1038
|
-
return result?.items ?? [];
|
|
1039
|
-
}
|
|
1040
|
-
/**
|
|
1041
|
-
* Install a marketplace plugin.
|
|
1042
|
-
*/
|
|
1043
|
-
async installPlugin(pluginId) {
|
|
1044
|
-
const client = this.getClient();
|
|
1045
|
-
const result = await client.cloud?.marketplace?.install?.(pluginId);
|
|
1046
|
-
return result ?? { success: false };
|
|
1047
|
-
}
|
|
1048
|
-
};
|
|
1049
|
-
|
|
1050
1100
|
// src/contracts.ts
|
|
1051
1101
|
function validatePluginContract(contract) {
|
|
1052
1102
|
const errors = [];
|
|
@@ -1504,7 +1554,7 @@ var FILTER_OPERATOR_ALIASES = {
|
|
|
1504
1554
|
before: "<",
|
|
1505
1555
|
after: ">"
|
|
1506
1556
|
};
|
|
1507
|
-
function
|
|
1557
|
+
function normalizeFilterOperator2(op) {
|
|
1508
1558
|
if (typeof op !== "string") return null;
|
|
1509
1559
|
const lower = op.toLowerCase();
|
|
1510
1560
|
return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
|
|
@@ -1533,7 +1583,7 @@ function objectFilterEntryToAST(entry) {
|
|
|
1533
1583
|
if (!entry || typeof entry !== "object") return null;
|
|
1534
1584
|
const field = entry.field;
|
|
1535
1585
|
const rawOp = entry.operator ?? entry.op ?? "=";
|
|
1536
|
-
const op =
|
|
1586
|
+
const op = normalizeFilterOperator2(rawOp);
|
|
1537
1587
|
if (!field || !op) return null;
|
|
1538
1588
|
return [String(field), op, entry.value];
|
|
1539
1589
|
}
|
|
@@ -1631,6 +1681,17 @@ function is404Error(error) {
|
|
|
1631
1681
|
const code = typeof err.code === "string" ? err.code : "";
|
|
1632
1682
|
return errorCodeIsAnyOf({ code }, ["OBJECT_NOT_FOUND", "RECORD_NOT_FOUND"]);
|
|
1633
1683
|
}
|
|
1684
|
+
var API_ACCESS_DENIED_CODES = [
|
|
1685
|
+
"OBJECT_API_DISABLED",
|
|
1686
|
+
"OBJECT_API_METHOD_NOT_ALLOWED"
|
|
1687
|
+
];
|
|
1688
|
+
function isApiAccessDeniedError(error) {
|
|
1689
|
+
return errorCodeIsAnyOf(error, API_ACCESS_DENIED_CODES);
|
|
1690
|
+
}
|
|
1691
|
+
var APP_PERMISSION_DENIED_CODE = "PERMISSION_DENIED";
|
|
1692
|
+
function isAppPermissionDeniedError(error) {
|
|
1693
|
+
return errorCodeIs(error, APP_PERMISSION_DENIED_CODE);
|
|
1694
|
+
}
|
|
1634
1695
|
var AnalyticsNotInstalledError = class extends Error {
|
|
1635
1696
|
constructor(surface, detail) {
|
|
1636
1697
|
super(
|
|
@@ -1715,6 +1776,19 @@ function normaliseClientError(error) {
|
|
|
1715
1776
|
message: typeof e.message === "string" ? e.message : void 0
|
|
1716
1777
|
});
|
|
1717
1778
|
}
|
|
1779
|
+
function formatHttpFailureMessage(message, meta) {
|
|
1780
|
+
if (!meta || typeof meta !== "object") return null;
|
|
1781
|
+
const method = typeof meta.method === "string" && meta.method ? meta.method : void 0;
|
|
1782
|
+
const url = typeof meta.url === "string" && meta.url ? meta.url : void 0;
|
|
1783
|
+
const status = typeof meta.status === "number" ? meta.status : typeof meta.statusCode === "number" ? meta.statusCode : void 0;
|
|
1784
|
+
if (!method && !url && status === void 0) return null;
|
|
1785
|
+
const errBody = meta.error && typeof meta.error === "object" ? meta.error : void 0;
|
|
1786
|
+
const rawCode = typeof meta.code === "string" && meta.code || (errBody && typeof errBody.code === "string" ? errBody.code : void 0);
|
|
1787
|
+
const parts = [method ?? "GET", url ?? "<unknown url>"];
|
|
1788
|
+
parts.push(`-> ${status ?? "no status"}`);
|
|
1789
|
+
if (rawCode) parts.push(`[${rawCode}]`);
|
|
1790
|
+
return `${message}: ${parts.join(" ")}`;
|
|
1791
|
+
}
|
|
1718
1792
|
function createQuietHttpLogger() {
|
|
1719
1793
|
const isExpected404 = (meta) => {
|
|
1720
1794
|
if (!meta || typeof meta !== "object") return false;
|
|
@@ -1732,12 +1806,15 @@ function createQuietHttpLogger() {
|
|
|
1732
1806
|
warn: (message, meta) => console.warn(message, meta ?? ""),
|
|
1733
1807
|
error: (message, error, meta) => {
|
|
1734
1808
|
if (isExpected404(meta)) {
|
|
1735
|
-
console.debug(
|
|
1809
|
+
console.debug(
|
|
1810
|
+
`[ObjectStack] ${formatHttpFailureMessage(message, meta) ?? message} (suppressed expected 404)`,
|
|
1811
|
+
meta
|
|
1812
|
+
);
|
|
1736
1813
|
return;
|
|
1737
1814
|
}
|
|
1738
|
-
console.error(message, error ?? "", meta ?? "");
|
|
1815
|
+
console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? "", meta ?? "");
|
|
1739
1816
|
},
|
|
1740
|
-
fatal: (message, error, meta) => console.error(message, error ?? "", meta ?? ""),
|
|
1817
|
+
fatal: (message, error, meta) => console.error(formatHttpFailureMessage(message, meta) ?? message, error ?? "", meta ?? ""),
|
|
1741
1818
|
log: (message, ...args) => console.log(message, ...args),
|
|
1742
1819
|
child: () => logger,
|
|
1743
1820
|
withTrace: () => logger
|
|
@@ -1751,6 +1828,60 @@ function stableStringify(value) {
|
|
|
1751
1828
|
const keys = Object.keys(obj).sort();
|
|
1752
1829
|
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
|
|
1753
1830
|
}
|
|
1831
|
+
function sameWireValue(a, b) {
|
|
1832
|
+
if (a === b) return true;
|
|
1833
|
+
const aEmpty = a === null || a === void 0;
|
|
1834
|
+
const bEmpty = b === null || b === void 0;
|
|
1835
|
+
if (aEmpty || bEmpty) return aEmpty && bEmpty;
|
|
1836
|
+
return stableStringify(a) === stableStringify(b);
|
|
1837
|
+
}
|
|
1838
|
+
function withoutNoOpDrops(droppedFields, sent, stored) {
|
|
1839
|
+
if (!sent || !stored || typeof sent !== "object" || typeof stored !== "object") {
|
|
1840
|
+
return droppedFields;
|
|
1841
|
+
}
|
|
1842
|
+
const out = [];
|
|
1843
|
+
for (const e of droppedFields) {
|
|
1844
|
+
const kept = e.fields.filter((f) => {
|
|
1845
|
+
if (!Object.prototype.hasOwnProperty.call(sent, f)) return true;
|
|
1846
|
+
if (!Object.prototype.hasOwnProperty.call(stored, f)) return true;
|
|
1847
|
+
return !sameWireValue(
|
|
1848
|
+
sent[f],
|
|
1849
|
+
stored[f]
|
|
1850
|
+
);
|
|
1851
|
+
});
|
|
1852
|
+
if (kept.length > 0) out.push({ ...e, fields: kept });
|
|
1853
|
+
}
|
|
1854
|
+
return out;
|
|
1855
|
+
}
|
|
1856
|
+
function viewItemObjectName(item) {
|
|
1857
|
+
const spec = item?.list ?? item;
|
|
1858
|
+
return spec?.data?.object ?? spec?.object ?? spec?.objectName;
|
|
1859
|
+
}
|
|
1860
|
+
function unwrapViewDraft(resp) {
|
|
1861
|
+
if (!resp || typeof resp !== "object") return null;
|
|
1862
|
+
const env = resp;
|
|
1863
|
+
const body = "item" in env ? env.item : env;
|
|
1864
|
+
if (!body || typeof body !== "object") return null;
|
|
1865
|
+
const spec = body.list ?? body;
|
|
1866
|
+
if (!spec || typeof spec !== "object") return null;
|
|
1867
|
+
return Object.keys(spec).length > 0 ? spec : null;
|
|
1868
|
+
}
|
|
1869
|
+
function readViewDeleteReceipt(result) {
|
|
1870
|
+
const r = result ?? void 0;
|
|
1871
|
+
return {
|
|
1872
|
+
removed: !!(r?.deleted ?? r?.reset ?? true),
|
|
1873
|
+
...typeof r?.reset === "boolean" ? { reset: r.reset } : {},
|
|
1874
|
+
...typeof r?.message === "string" ? { message: r.message } : {}
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
function mergeViewPatch(current, partial, viewName, objectName) {
|
|
1878
|
+
return {
|
|
1879
|
+
...current,
|
|
1880
|
+
...partial,
|
|
1881
|
+
name: viewName,
|
|
1882
|
+
object: current?.object || current?.data?.object || objectName
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1754
1885
|
var ObjectStackAdapter = class {
|
|
1755
1886
|
constructor(config) {
|
|
1756
1887
|
__publicField(this, "client");
|
|
@@ -1803,7 +1934,14 @@ var ObjectStackAdapter = class {
|
|
|
1803
1934
|
// whose response carried `droppedFields` (framework #3431/#3455) so the app
|
|
1804
1935
|
// shell can surface a toast instead of the strip passing silently.
|
|
1805
1936
|
__publicField(this, "writeWarningListeners", /* @__PURE__ */ new Set());
|
|
1937
|
+
// Subscribers registered via onSaveAdvisory(). Emitted after a metadata save
|
|
1938
|
+
// through THIS adapter's `ObjectStackClient` whose 200 carried a non-empty
|
|
1939
|
+
// `advisories` array (#4237; backend objectstack#7435). Sibling of the set
|
|
1940
|
+
// above in every respect except which door produced the event: that one is
|
|
1941
|
+
// record CRUD, this one is the metadata save door.
|
|
1942
|
+
__publicField(this, "saveAdvisoryListeners", /* @__PURE__ */ new Set());
|
|
1806
1943
|
this.client = new import_client.ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
|
|
1944
|
+
this.installSaveAdvisoryInterceptor();
|
|
1807
1945
|
this.metadataCache = new MetadataCache(config.cache);
|
|
1808
1946
|
this.autoReconnect = config.autoReconnect ?? true;
|
|
1809
1947
|
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3;
|
|
@@ -1958,7 +2096,7 @@ var ObjectStackAdapter = class {
|
|
|
1958
2096
|
const result = await this.client.data.find(resource, queryOptions);
|
|
1959
2097
|
return this.normalizeQueryResult(result, params);
|
|
1960
2098
|
} catch (err) {
|
|
1961
|
-
if (is404Error(err)) {
|
|
2099
|
+
if (!isApiAccessDeniedError(err) && is404Error(err)) {
|
|
1962
2100
|
this.missingResources.add(resource);
|
|
1963
2101
|
return { data: [], total: 0 };
|
|
1964
2102
|
}
|
|
@@ -2127,12 +2265,14 @@ var ObjectStackAdapter = class {
|
|
|
2127
2265
|
* whose response type predates `droppedFields`: the field is read structurally
|
|
2128
2266
|
* and validated, so an older client (or a backend that never drops) is a no-op.
|
|
2129
2267
|
*/
|
|
2130
|
-
notifyDroppedFields(operation, resource, result, id) {
|
|
2268
|
+
notifyDroppedFields(operation, resource, result, id, sent) {
|
|
2131
2269
|
const dropped = result?.droppedFields;
|
|
2132
2270
|
if (!Array.isArray(dropped) || dropped.length === 0) return;
|
|
2133
|
-
const
|
|
2271
|
+
const valid = dropped.filter(
|
|
2134
2272
|
(e) => !!e && typeof e === "object" && Array.isArray(e.fields) && e.fields.length > 0
|
|
2135
2273
|
);
|
|
2274
|
+
const stored = result?.record;
|
|
2275
|
+
const droppedFields = withoutNoOpDrops(valid, sent, stored);
|
|
2136
2276
|
if (droppedFields.length === 0) return;
|
|
2137
2277
|
this.emitWriteWarning({ operation, resource, ...id !== void 0 ? { id } : {}, droppedFields });
|
|
2138
2278
|
}
|
|
@@ -2151,17 +2291,25 @@ var ObjectStackAdapter = class {
|
|
|
2151
2291
|
notifyBatchDroppedFields(operations, payload) {
|
|
2152
2292
|
const dropped = payload?.droppedFields;
|
|
2153
2293
|
if (!Array.isArray(dropped) || dropped.length === 0) return;
|
|
2294
|
+
const results = payload?.results;
|
|
2154
2295
|
for (const entry of dropped) {
|
|
2155
2296
|
if (!entry || typeof entry !== "object") continue;
|
|
2156
2297
|
const e = entry;
|
|
2157
2298
|
if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
|
|
2158
2299
|
const op = typeof e.index === "number" ? operations[e.index] : void 0;
|
|
2300
|
+
const stored = typeof e.index === "number" && Array.isArray(results) ? results[e.index] : void 0;
|
|
2301
|
+
const [live] = withoutNoOpDrops(
|
|
2302
|
+
[{ object: e.object, fields: e.fields, reason: e.reason }],
|
|
2303
|
+
op?.data,
|
|
2304
|
+
stored
|
|
2305
|
+
);
|
|
2306
|
+
if (!live) continue;
|
|
2159
2307
|
const operation = (op?.action ?? "create") === "create" ? "create" : "update";
|
|
2160
2308
|
this.emitWriteWarning({
|
|
2161
2309
|
operation,
|
|
2162
2310
|
resource: e.object ?? op?.object ?? "",
|
|
2163
2311
|
...op?.id !== void 0 && op?.id !== null ? { id: op.id } : {},
|
|
2164
|
-
droppedFields: [
|
|
2312
|
+
droppedFields: [live]
|
|
2165
2313
|
});
|
|
2166
2314
|
}
|
|
2167
2315
|
}
|
|
@@ -2176,12 +2324,127 @@ var ObjectStackAdapter = class {
|
|
|
2176
2324
|
this.writeWarningListeners.delete(callback);
|
|
2177
2325
|
};
|
|
2178
2326
|
}
|
|
2327
|
+
/**
|
|
2328
|
+
* Subscribe to metadata save-advisory events — the runtime authoring gate's
|
|
2329
|
+
* advisory findings on a save that SUCCEEDED (#4237; backend
|
|
2330
|
+
* objectstack#7435). Returns an unsubscribe function.
|
|
2331
|
+
*
|
|
2332
|
+
* Deliberately the same seam as {@link onWriteWarning} (#3431/#3455), which
|
|
2333
|
+
* is what {@link MetadataSaveAdvisoryEvent}'s own declaration already said it
|
|
2334
|
+
* was modelled on. It is a SIBLING of that channel rather than a second
|
|
2335
|
+
* payload pushed down it: `WriteWarningEvent` is a closed shape whose
|
|
2336
|
+
* `droppedFields` is required and means "fields the write legally stripped",
|
|
2337
|
+
* so carrying advisories on it would either force every existing
|
|
2338
|
+
* `onWriteWarning` consumer to grow a branch or make the event lie about what
|
|
2339
|
+
* happened. The seam's SHAPE is what is reused here — a long-lived instance
|
|
2340
|
+
* with a `subscribe → unsubscribe` registration that `AdapterProvider` wires
|
|
2341
|
+
* once — not its event type.
|
|
2342
|
+
*
|
|
2343
|
+
* Why here and not on the config, which is how the other client class does it
|
|
2344
|
+
* (#4133/#4236): `MetadataClient` is minted per component by
|
|
2345
|
+
* `useMetadataClient`, so it has no instance to subscribe to and its sink
|
|
2346
|
+
* rides the factory. `ObjectStackAdapter` is the opposite — one long-lived
|
|
2347
|
+
* instance per app, already carrying this exact subscription pattern.
|
|
2348
|
+
*/
|
|
2349
|
+
onSaveAdvisory(callback) {
|
|
2350
|
+
this.saveAdvisoryListeners.add(callback);
|
|
2351
|
+
return () => {
|
|
2352
|
+
this.saveAdvisoryListeners.delete(callback);
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2355
|
+
/**
|
|
2356
|
+
* Notify all save-advisory subscribers. Isolated exactly like
|
|
2357
|
+
* {@link emitWriteWarning}: a throwing listener must neither break the save
|
|
2358
|
+
* nor starve the others.
|
|
2359
|
+
*/
|
|
2360
|
+
emitSaveAdvisory(event) {
|
|
2361
|
+
for (const listener of this.saveAdvisoryListeners) {
|
|
2362
|
+
try {
|
|
2363
|
+
listener(event);
|
|
2364
|
+
} catch (err) {
|
|
2365
|
+
console.warn("ObjectStackAdapter: save-advisory listener error", err);
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
/**
|
|
2370
|
+
* Install the ONE emitter for the metadata save door (#4237).
|
|
2371
|
+
*
|
|
2372
|
+
* ## Why this seam, and what it covers
|
|
2373
|
+
*
|
|
2374
|
+
* `ObjectStackClient.meta.saveItem` is the second client class that writes
|
|
2375
|
+
* through `PUT /api/v1/meta/:type/:name`, and every one of its callers reaches
|
|
2376
|
+
* it through an adapter this class constructed — the four inside this file
|
|
2377
|
+
* (`updateViewConfig`, the two view paths, `updateDashboard`) via
|
|
2378
|
+
* `this.client`, and every caller outside it via {@link getClient}, which
|
|
2379
|
+
* hands back this same instance: `MetadataService` (app-shell, five saves),
|
|
2380
|
+
* `useNavigationSync`, and plugin-designer's Create/EditAppPage. Wrapping the
|
|
2381
|
+
* method once here therefore covers all of them WITHOUT a per-site edit, which
|
|
2382
|
+
* is the whole point — a toast copied into a dozen call sites is the shape
|
|
2383
|
+
* #4133 rejected for the other client class and it is no better here.
|
|
2384
|
+
*
|
|
2385
|
+
* `meta` is an own, writable property assigned per instance in the SDK's
|
|
2386
|
+
* constructor (`this.meta = { … }`), and the client this adapter builds is
|
|
2387
|
+
* never shared, so the wrap is bounded to an object this adapter owns for its
|
|
2388
|
+
* whole lifetime. It is not a prototype or global patch.
|
|
2389
|
+
*
|
|
2390
|
+
* ## Response shape — measured, not assumed
|
|
2391
|
+
*
|
|
2392
|
+
* The two client classes' envelopes coincide at the top level, which is what
|
|
2393
|
+
* makes `readSaveAdvisories` reusable unchanged across both. `SaveMetaItem-
|
|
2394
|
+
* ResponseSchema` puts `advisories` at the body's top level next to
|
|
2395
|
+
* `success` / `version` / `seq` / `state`, and the SDK's `unwrapResponse`
|
|
2396
|
+
* strips its `{ success, data }` envelope only when the body actually HAS a
|
|
2397
|
+
* `data` key — this body does not, so it is returned verbatim. So the same
|
|
2398
|
+
* reader that `MetadataClient.save` uses reads this response correctly, and
|
|
2399
|
+
* the pins in `onSaveAdvisory.test.ts` drive a real SDK client through a fake
|
|
2400
|
+
* `fetch` rather than stubbing `meta`, so that continues to be measured.
|
|
2401
|
+
*
|
|
2402
|
+
* ## Draft-door honesty (D1)
|
|
2403
|
+
*
|
|
2404
|
+
* Drafts are NEVER gated: the framework returns at its D1 early-return
|
|
2405
|
+
* (`if (args.state !== 'active') return null`) before running a rule, so a
|
|
2406
|
+
* draft save produces no findings to withhold. This client class has no draft
|
|
2407
|
+
* door at all to worry about — the SDK's `saveItem(type, name, item)` takes no
|
|
2408
|
+
* mode and always writes the active door, which is exactly why the gate DOES
|
|
2409
|
+
* run for its callers. `mode` on the emitted event is therefore derived from
|
|
2410
|
+
* the response's own `state` rather than from a request-side flag that does
|
|
2411
|
+
* not exist here: `'draft'` when the server says the row landed as a draft,
|
|
2412
|
+
* `'publish'` otherwise. That keeps the event truthful about which door it
|
|
2413
|
+
* came through instead of hard-coding one.
|
|
2414
|
+
*/
|
|
2415
|
+
installSaveAdvisoryInterceptor() {
|
|
2416
|
+
const meta = this.client.meta;
|
|
2417
|
+
const original = meta.saveItem.bind(meta);
|
|
2418
|
+
meta.saveItem = async (type, name, item) => {
|
|
2419
|
+
const result = await original(type, name, item);
|
|
2420
|
+
try {
|
|
2421
|
+
const advisories = readSaveAdvisories(result);
|
|
2422
|
+
if (advisories.length > 0) {
|
|
2423
|
+
this.emitSaveAdvisory({
|
|
2424
|
+
type,
|
|
2425
|
+
name,
|
|
2426
|
+
mode: result?.state === "draft" ? "draft" : "publish",
|
|
2427
|
+
advisories
|
|
2428
|
+
});
|
|
2429
|
+
}
|
|
2430
|
+
} catch (err) {
|
|
2431
|
+
console.warn("ObjectStackAdapter: save-advisory read error", err);
|
|
2432
|
+
}
|
|
2433
|
+
return result;
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2179
2436
|
async create(resource, data) {
|
|
2180
2437
|
await this.connect();
|
|
2181
2438
|
try {
|
|
2182
2439
|
const result = await this.client.data.create(resource, data);
|
|
2183
2440
|
this.emitMutation({ type: "create", resource, record: { ...result.record } });
|
|
2184
|
-
this.notifyDroppedFields(
|
|
2441
|
+
this.notifyDroppedFields(
|
|
2442
|
+
"create",
|
|
2443
|
+
resource,
|
|
2444
|
+
result,
|
|
2445
|
+
result.record?.id,
|
|
2446
|
+
data
|
|
2447
|
+
);
|
|
2185
2448
|
return result.record;
|
|
2186
2449
|
} catch (err) {
|
|
2187
2450
|
throw normaliseClientError(err);
|
|
@@ -2208,7 +2471,7 @@ var ObjectStackAdapter = class {
|
|
|
2208
2471
|
opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
|
|
2209
2472
|
);
|
|
2210
2473
|
this.emitMutation({ type: "update", resource, id, record: { ...result.record } });
|
|
2211
|
-
this.notifyDroppedFields("update", resource, result, id);
|
|
2474
|
+
this.notifyDroppedFields("update", resource, result, id, data);
|
|
2212
2475
|
return result.record;
|
|
2213
2476
|
} catch (err) {
|
|
2214
2477
|
throw normaliseClientError(err);
|
|
@@ -2229,10 +2492,10 @@ var ObjectStackAdapter = class {
|
|
|
2229
2492
|
String(id),
|
|
2230
2493
|
opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
|
|
2231
2494
|
);
|
|
2232
|
-
if (result.
|
|
2495
|
+
if (result.success) {
|
|
2233
2496
|
this.emitMutation({ type: "delete", resource, id });
|
|
2234
2497
|
}
|
|
2235
|
-
return result.
|
|
2498
|
+
return result.success;
|
|
2236
2499
|
} catch (err) {
|
|
2237
2500
|
throw normaliseClientError(err);
|
|
2238
2501
|
}
|
|
@@ -2775,6 +3038,8 @@ var ObjectStackAdapter = class {
|
|
|
2775
3038
|
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
|
|
2776
3039
|
const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
|
|
2777
3040
|
err.status = res.status;
|
|
3041
|
+
err.code = errorBody?.code ?? errorBody?.error?.code;
|
|
3042
|
+
err.httpStatus = res.status;
|
|
2778
3043
|
throw err;
|
|
2779
3044
|
}
|
|
2780
3045
|
const body = await res.json();
|
|
@@ -3033,42 +3298,109 @@ var ObjectStackAdapter = class {
|
|
|
3033
3298
|
return null;
|
|
3034
3299
|
}
|
|
3035
3300
|
}
|
|
3301
|
+
/**
|
|
3302
|
+
* **The one place that knows which cache keys a view-row write invalidates.**
|
|
3303
|
+
*
|
|
3304
|
+
* Two reads are cached under view-shaped keys, and both go stale when any
|
|
3305
|
+
* writer touches a view row for `objectName`:
|
|
3306
|
+
*
|
|
3307
|
+
* | reader | key |
|
|
3308
|
+
* |----------------------------|----------------------------|
|
|
3309
|
+
* | {@link getView} | `view:{object}:{viewName}` |
|
|
3310
|
+
* | {@link listViewOverrides} | `view-overrides:{object}` |
|
|
3311
|
+
*
|
|
3312
|
+
* Every writer routes here rather than restating that pair. The repo has now
|
|
3313
|
+
* paid twice for restatement: objectui#3778 removed five copies of a key
|
|
3314
|
+
* (`views:{object}`) that no reader had ever populated, and objectui#4363
|
|
3315
|
+
* fixed four copies that named only half the live set — and objectui#4373 is
|
|
3316
|
+
* the measured proof that the failure recurs by default, because the console's
|
|
3317
|
+
* real create/publish flow writes these same rows through the ADR-0034
|
|
3318
|
+
* metadata seam and never learned the key list at all. A pin suite can only
|
|
3319
|
+
* guard the writers that exist; a mandatory seam makes the next writer
|
|
3320
|
+
* structurally unable to forget.
|
|
3321
|
+
*
|
|
3322
|
+
* The rule is uniform per WRITE, not per branch: a write to a view row drops
|
|
3323
|
+
* both keys, in that order. Draft-addressed writes over-invalidate on purpose
|
|
3324
|
+
* — both readers enumerate PUBLISHED rows, so a draft write stales neither —
|
|
3325
|
+
* because the costs are not symmetric. An unnecessary invalidation costs one
|
|
3326
|
+
* refetch; a missed one costs up to the cache's 5-minute TTL of stale
|
|
3327
|
+
* overrides, and "which half am I in?" is not a question a future writer
|
|
3328
|
+
* should have to re-answer to stay correct.
|
|
3329
|
+
*
|
|
3330
|
+
* Public because the writers are not all in this class: app-shell's ADR-0034
|
|
3331
|
+
* seam (`runtime-metadata-persistence.ts`) and `MetadataService` write the
|
|
3332
|
+
* same rows through `client.meta` / the `/meta` draft-publish API, and they
|
|
3333
|
+
* reach this adapter as the `dataSource` they were already handed.
|
|
3334
|
+
*
|
|
3335
|
+
* @param objectName - Object the view belongs to (the `{object}` in both keys)
|
|
3336
|
+
* @param viewName - The view's canonical `name` — the identity
|
|
3337
|
+
* {@link getView} reads back by and {@link listViewOverrides} keys its map
|
|
3338
|
+
* with. Callers that hold a qualified `<object>.<key>` name pass it as-is:
|
|
3339
|
+
* it is the row key, not a path to be split.
|
|
3340
|
+
*/
|
|
3341
|
+
invalidateViewKeys(objectName, viewName) {
|
|
3342
|
+
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
|
|
3343
|
+
this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
|
|
3344
|
+
}
|
|
3036
3345
|
/**
|
|
3037
3346
|
* Batch-fetch all persisted view overrides for an object.
|
|
3038
3347
|
*
|
|
3039
|
-
* Per-view runtime overrides (density, column widths, sort,
|
|
3040
|
-
*
|
|
3041
|
-
*
|
|
3042
|
-
*
|
|
3043
|
-
*
|
|
3044
|
-
*
|
|
3045
|
-
* returns
|
|
3046
|
-
*
|
|
3348
|
+
* Per-view runtime overrides (density, column widths, sort, hidden
|
|
3349
|
+
* columns, inlineEdit …) live in the SAME metadata namespace the
|
|
3350
|
+
* write path uses: `type='view'`, `name=<viewId>` (see
|
|
3351
|
+
* {@link updateViewConfig}). Loading them per-view fires N HTTP GETs
|
|
3352
|
+
* that 404 for every view the user never customized — console noise on
|
|
3353
|
+
* every page load. This batch method performs a single
|
|
3354
|
+
* `GET /api/v1/meta/view` (returns `{type, items}`) and narrows the
|
|
3355
|
+
* result to `objectName` client-side, exactly as {@link listViews}
|
|
3356
|
+
* does over the same rows (shared accessor: {@link viewItemObjectName}).
|
|
3357
|
+
*
|
|
3358
|
+
* objectui#3774 — this used to enumerate `GET /api/v1/meta/<objectName>`,
|
|
3359
|
+
* putting the OBJECT name in the metadata TYPE slot. That key space is
|
|
3360
|
+
* disjoint from the one the write path lands in, so the batch map came
|
|
3361
|
+
* back empty for every object, forever, and every saved personalization
|
|
3362
|
+
* read back as "setting didn't save".
|
|
3363
|
+
*
|
|
3364
|
+
* FAILURES REJECT — they are not answered as `{}`. An empty map is an
|
|
3365
|
+
* authoritative "this object has no overrides" (callers may trust it and
|
|
3366
|
+
* skip the per-view reads); a transport/permission failure is "I could
|
|
3367
|
+
* not tell", and reporting the two as the same value is what made
|
|
3368
|
+
* ObjectView's per-view {@link getView} fallback unreachable code. When
|
|
3369
|
+
* we cannot tell, we do not pretend we can. Rejections are not cached
|
|
3370
|
+
* (the cache stores on success only), so a transient failure does not
|
|
3371
|
+
* pin an empty answer for the TTL.
|
|
3047
3372
|
*
|
|
3048
|
-
* Result is cached identically to {@link getView}
|
|
3049
|
-
*
|
|
3373
|
+
* Result is cached identically to {@link getView}, and EVERY view write —
|
|
3374
|
+
* on this adapter and above it — invalidates it through the one seam,
|
|
3375
|
+
* {@link invalidateViewKeys}. For a long time only {@link updateViewConfig}
|
|
3376
|
+
* did (objectui#4363), which left the other three adapter paths stale
|
|
3377
|
+
* for the cache's 5-minute TTL. That gap does not self-heal: the consumer
|
|
3378
|
+
* (`loadViewOverrides`, app-shell `ObjectView`) treats a RESOLVED map as
|
|
3379
|
+
* authoritative and deliberately does not re-probe per view — objectui#3774,
|
|
3380
|
+
* and correct, since re-probing reinstates the 404 flurry the batch read
|
|
3381
|
+
* exists to remove. So a stale map here is served in full, and the per-view
|
|
3382
|
+
* {@link getView} fallback that would have masked it never runs.
|
|
3050
3383
|
*
|
|
3051
3384
|
* @param objectName - Object name (e.g. 'lead')
|
|
3052
3385
|
* @returns Map keyed by view name with the persisted override config
|
|
3386
|
+
* @throws whatever the metadata transport throws — callers that have a
|
|
3387
|
+
* per-view fallback should catch and use it.
|
|
3053
3388
|
*/
|
|
3054
3389
|
async listViewOverrides(objectName) {
|
|
3055
3390
|
await this.connect();
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
} catch {
|
|
3070
|
-
return {};
|
|
3071
|
-
}
|
|
3391
|
+
const cacheKey = `view-overrides:${objectName}`;
|
|
3392
|
+
return await this.metadataCache.get(cacheKey, async () => {
|
|
3393
|
+
const result = await this.client.meta.getItems("view");
|
|
3394
|
+
const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
|
|
3395
|
+
const out = {};
|
|
3396
|
+
for (const it of items) {
|
|
3397
|
+
if (!it || typeof it !== "object") continue;
|
|
3398
|
+
if (viewItemObjectName(it) !== objectName) continue;
|
|
3399
|
+
const key = it.name;
|
|
3400
|
+
if (typeof key === "string" && key) out[key] = it;
|
|
3401
|
+
}
|
|
3402
|
+
return out;
|
|
3403
|
+
});
|
|
3072
3404
|
}
|
|
3073
3405
|
/**
|
|
3074
3406
|
* Get a view definition for an object.
|
|
@@ -3118,10 +3450,7 @@ var ObjectStackAdapter = class {
|
|
|
3118
3450
|
viewId,
|
|
3119
3451
|
merged
|
|
3120
3452
|
);
|
|
3121
|
-
|
|
3122
|
-
this.metadataCache.invalidate?.(cacheKey);
|
|
3123
|
-
this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
|
|
3124
|
-
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
3453
|
+
this.invalidateViewKeys(objectName, viewId);
|
|
3125
3454
|
if (result && result.item) return result.item;
|
|
3126
3455
|
return result ?? void 0;
|
|
3127
3456
|
}
|
|
@@ -3132,8 +3461,10 @@ var ObjectStackAdapter = class {
|
|
|
3132
3461
|
* match the view spec shape.
|
|
3133
3462
|
*
|
|
3134
3463
|
* Returns view spec objects with their canonical `name` as identifier.
|
|
3135
|
-
*
|
|
3136
|
-
*
|
|
3464
|
+
* Narrows to one object client-side via {@link viewItemObjectName} —
|
|
3465
|
+
* the metadata index is name-only, not field-typed, so the route has no
|
|
3466
|
+
* `?object=` to push the filter down into. {@link listViewOverrides}
|
|
3467
|
+
* reads the same rows through the same accessor.
|
|
3137
3468
|
*/
|
|
3138
3469
|
async listViews(objectName, options) {
|
|
3139
3470
|
await this.connect();
|
|
@@ -3150,8 +3481,7 @@ var ObjectStackAdapter = class {
|
|
|
3150
3481
|
return items.filter((v) => {
|
|
3151
3482
|
if (!v) return false;
|
|
3152
3483
|
const spec = v.list ?? v;
|
|
3153
|
-
|
|
3154
|
-
if (obj !== objectName) return false;
|
|
3484
|
+
if (viewItemObjectName(v) !== objectName) return false;
|
|
3155
3485
|
const viewKind = v.viewKind ?? spec?.viewKind;
|
|
3156
3486
|
return !(viewKind && FORM_FAMILY.has(viewKind));
|
|
3157
3487
|
}).map((v) => {
|
|
@@ -3212,6 +3542,10 @@ var ObjectStackAdapter = class {
|
|
|
3212
3542
|
*
|
|
3213
3543
|
* Generates a snake_case name if `spec.name` is not provided by appending
|
|
3214
3544
|
* a short timestamp suffix to the source-name hint.
|
|
3545
|
+
*
|
|
3546
|
+
* Invalidates through {@link invalidateViewKeys}, like every other writer —
|
|
3547
|
+
* see {@link listViewOverrides} for why the batch map is the one that cannot
|
|
3548
|
+
* heal itself (objectui#4363).
|
|
3215
3549
|
*/
|
|
3216
3550
|
async createView(objectName, spec) {
|
|
3217
3551
|
await this.connect();
|
|
@@ -3231,48 +3565,184 @@ var ObjectStackAdapter = class {
|
|
|
3231
3565
|
data: spec?.data || { provider: "object", object: objectName }
|
|
3232
3566
|
};
|
|
3233
3567
|
const result = await this.client.meta.saveItem("view", name, fullSpec);
|
|
3234
|
-
this.
|
|
3568
|
+
this.invalidateViewKeys(objectName, name);
|
|
3235
3569
|
if (result && result.item) return result.item;
|
|
3236
3570
|
return fullSpec;
|
|
3237
3571
|
}
|
|
3238
3572
|
/**
|
|
3239
3573
|
* Apply a partial update to an existing overlay view. Reads the current
|
|
3240
|
-
*
|
|
3241
|
-
*
|
|
3242
|
-
*
|
|
3574
|
+
* document, merges, and writes it back. ADR-0005 overlay rows store the
|
|
3575
|
+
* *full* view document, so partial updates require a read-merge-write cycle.
|
|
3576
|
+
*
|
|
3577
|
+
* **Both halves address the same row (#4139).** A view has two possible
|
|
3578
|
+
* homes and the read must resolve the one the write will target:
|
|
3579
|
+
*
|
|
3580
|
+
* - a pending per-item **draft** (`?state=draft` / `?mode=draft`) — where
|
|
3581
|
+
* ADR-0034 stages every runtime-created view, so a view made from the `+`
|
|
3582
|
+
* tab lives ONLY here until an explicit Publish;
|
|
3583
|
+
* - the **published** overlay (`client.meta.getItem` / `saveItem`).
|
|
3584
|
+
*
|
|
3585
|
+
* The draft is probed FIRST, and a hit is merged and written straight back
|
|
3586
|
+
* as a draft. Two things that ordering buys, both load-bearing:
|
|
3587
|
+
*
|
|
3588
|
+
* 1. A draft-only view is no longer invisible to the read. It used to 404,
|
|
3589
|
+
* and a `catch {}` labelled "treat missing as create-equivalent"
|
|
3590
|
+
* substituted `current = {}` — so a rename merged onto nothing and went
|
|
3591
|
+
* out as a `{label, name, object}` partial the server rejects (422),
|
|
3592
|
+
* while the draft row the UI reads back through `?preview=draft` kept the
|
|
3593
|
+
* old label. The edit was lost with no error surfaced to the user.
|
|
3594
|
+
* 2. A draft is never bypassed. Writing the published row while a draft is
|
|
3595
|
+
* pending would put the edit somewhere the draft shadows — and Publish
|
|
3596
|
+
* would then overwrite it with the pre-edit body, losing the change a
|
|
3597
|
+
* second time, later, where nothing connects it to this call.
|
|
3598
|
+
*
|
|
3599
|
+
* A draft edit stays a draft: `mode: 'draft'` keeps ADR-0037's guarantee
|
|
3600
|
+
* that nothing the preview shows goes live until Publish. Renaming a
|
|
3601
|
+
* *published* view (no draft pending) is unchanged — it writes the
|
|
3602
|
+
* published overlay, as before.
|
|
3603
|
+
*
|
|
3604
|
+
* **Both halves invalidate through {@link invalidateViewKeys}** (objectui#4363,
|
|
3605
|
+
* #4373) — same call, same key set, so the draft half's deliberate
|
|
3606
|
+
* over-invalidation is the seam's uniform rule rather than a per-branch
|
|
3607
|
+
* decision this method has to keep re-making.
|
|
3608
|
+
*
|
|
3609
|
+
* @throws when the view resolves in neither home, or when either read fails
|
|
3610
|
+
* for any other reason (network, permission). Both used to be swallowed
|
|
3611
|
+
* and converted into the bad partial write above; a caller that wants a
|
|
3612
|
+
* view created should call {@link createView}, which is the operation that
|
|
3613
|
+
* actually means "create".
|
|
3243
3614
|
*/
|
|
3244
3615
|
async updateView(objectName, viewName, partial) {
|
|
3245
3616
|
await this.connect();
|
|
3246
|
-
|
|
3617
|
+
const metaClient = this.metadataClient();
|
|
3618
|
+
const draft = unwrapViewDraft(
|
|
3619
|
+
await metaClient.get("view", viewName, { state: "draft" })
|
|
3620
|
+
);
|
|
3621
|
+
if (draft) {
|
|
3622
|
+
const mergedDraft = mergeViewPatch(draft, partial, viewName, objectName);
|
|
3623
|
+
await metaClient.save("view", viewName, mergedDraft, { mode: "draft" });
|
|
3624
|
+
this.invalidateViewKeys(objectName, viewName);
|
|
3625
|
+
return mergedDraft;
|
|
3626
|
+
}
|
|
3627
|
+
let current;
|
|
3247
3628
|
try {
|
|
3248
3629
|
const r = await this.client.meta.getItem("view", viewName);
|
|
3249
3630
|
current = r && (r.item || r) || {};
|
|
3250
3631
|
if (current?.list) current = current.list;
|
|
3251
|
-
} catch {
|
|
3632
|
+
} catch (err) {
|
|
3633
|
+
if (is404Error(err)) {
|
|
3634
|
+
throw Object.assign(
|
|
3635
|
+
new Error(
|
|
3636
|
+
`updateView: view "${viewName}" not found on object "${objectName}" \u2014 no pending draft and no published overlay. Use createView() to create one.`
|
|
3637
|
+
),
|
|
3638
|
+
{ cause: err }
|
|
3639
|
+
);
|
|
3640
|
+
}
|
|
3641
|
+
throw err;
|
|
3252
3642
|
}
|
|
3253
|
-
const merged =
|
|
3254
|
-
...current,
|
|
3255
|
-
...partial,
|
|
3256
|
-
name: viewName,
|
|
3257
|
-
object: current?.object || current?.data?.object || objectName
|
|
3258
|
-
};
|
|
3643
|
+
const merged = mergeViewPatch(current, partial, viewName, objectName);
|
|
3259
3644
|
const result = await this.client.meta.saveItem("view", viewName, merged);
|
|
3260
|
-
this.
|
|
3261
|
-
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
|
|
3645
|
+
this.invalidateViewKeys(objectName, viewName);
|
|
3262
3646
|
if (result && result.item) return result.item;
|
|
3263
3647
|
return merged;
|
|
3264
3648
|
}
|
|
3265
3649
|
/**
|
|
3266
|
-
* Delete an overlay view
|
|
3267
|
-
*
|
|
3268
|
-
*
|
|
3650
|
+
* Delete an overlay view — from **every home it has**.
|
|
3651
|
+
*
|
|
3652
|
+
* A view has two possible homes, the same two {@link updateView} addresses:
|
|
3653
|
+
* the pending per-item **draft** (`DELETE /meta/view/:name?state=draft`) and
|
|
3654
|
+
* the **published** overlay (`DELETE /meta/view/:name`). This method used to
|
|
3655
|
+
* issue only the second, and the three cases came out like this (#4479):
|
|
3656
|
+
*
|
|
3657
|
+
* | case | before |
|
|
3658
|
+
* |--------------------|-----------------------------------------------------|
|
|
3659
|
+
* | draft-only view | BUG — the published delete answered `reset:false` / |
|
|
3660
|
+
* | | "nothing to delete", the draft survived, and the tab |
|
|
3661
|
+
* | | was still there after reload |
|
|
3662
|
+
* | published-only | correct — `reset:true`, tab gone |
|
|
3663
|
+
* | published + draft | ACCIDENTALLY correct — the published row went, so |
|
|
3664
|
+
* | | the tab went; the orphan draft stayed behind |
|
|
3665
|
+
*
|
|
3666
|
+
* **Why this is not the mechanical mirror of #4139.** `updateView` probes
|
|
3667
|
+
* the draft first and writes back to whichever home the read resolved, and
|
|
3668
|
+
* that is right for an update in all three cases. Copying it here would be
|
|
3669
|
+
* wrong in the third: `persistRuntimeMetadata` (app-shell) stages EVERY
|
|
3670
|
+
* runtime edit as a draft, so "publish a view, then edit it" routinely
|
|
3671
|
+
* produces a pair — and a draft-first-only delete on a pair discards the
|
|
3672
|
+
* draft and leaves the published row serving the view. That is not Delete
|
|
3673
|
+
* view, it is **Discard draft**, a deliberately different operation that
|
|
3674
|
+
* already exists (`discardRuntimeDraft`, documented as "the published
|
|
3675
|
+
* overlay is untouched"). The clean statement of the asymmetry: for an
|
|
3676
|
+
* update, one home is the right home; for a delete, "remove this view" is
|
|
3677
|
+
* satisfied only when NO home is left serving it.
|
|
3678
|
+
*
|
|
3679
|
+
* So both homes are deleted, **draft first**. The order is load-bearing on
|
|
3680
|
+
* the failure path: a fault between the two calls leaves the PUBLISHED
|
|
3681
|
+
* overlay intact, so the view is still served and the operation is cleanly
|
|
3682
|
+
* retryable. The reverse order would strand a draft-only view — which is
|
|
3683
|
+
* precisely the bug shape above.
|
|
3684
|
+
*
|
|
3685
|
+
* **Two blind calls, no probe.** The framework's `deleteMetaItem` answers a
|
|
3686
|
+
* missing home with a **200** carrying `reset:false` (`"No pending draft
|
|
3687
|
+
* for view/x."` / `"No view 'x' found — nothing to delete."`), never a 404,
|
|
3688
|
+
* so there is nothing for a probe to protect against. `updateView` needs its
|
|
3689
|
+
* probe for a different reason — its read must resolve the row the merge
|
|
3690
|
+
* writes back to — and that reason has no counterpart for a delete.
|
|
3691
|
+
*
|
|
3692
|
+
* **One transport, one error contract.** Both halves go through
|
|
3693
|
+
* {@link MetadataClient} (`reset`), the transport that can express the
|
|
3694
|
+
* `?state=` qualifier and the one `updateView`'s draft half already uses.
|
|
3695
|
+
* The published half used to go through `client.meta.deleteItem`; measured,
|
|
3696
|
+
* that issues the byte-identical request (`DELETE
|
|
3697
|
+
* {baseUrl}/api/v1/meta/view/:name`, no environment scoping is configured on
|
|
3698
|
+
* this adapter), so routing it here costs no addressing change and buys a
|
|
3699
|
+
* single `MetadataError` shape across both calls instead of two.
|
|
3700
|
+
*
|
|
3701
|
+
* @returns `deleted` is true only when no home is left serving the view AND
|
|
3702
|
+
* at least one actually held a row. A view that existed in neither home
|
|
3703
|
+
* still answers `false`, unchanged. The per-home outcomes are additive:
|
|
3704
|
+
* a partial result is observable rather than rounded up to `true`.
|
|
3705
|
+
* @throws when either delete fails, matching {@link updateView}'s
|
|
3706
|
+
* convention of surfacing the fault rather than degrading. A failure of
|
|
3707
|
+
* the PUBLISHED half after the draft was discarded carries the partial
|
|
3708
|
+
* state on the error's `outcome` — "draft gone, overlay left" is exactly
|
|
3709
|
+
* what the old `{ deleted: boolean }` could not express.
|
|
3710
|
+
*
|
|
3711
|
+
* Invalidates through {@link invalidateViewKeys}: the deleted row leaves the
|
|
3712
|
+
* batch override map too, and a ghost entry there is what the object page
|
|
3713
|
+
* would keep applying (objectui#4363). Fired in a `finally`, so it happens
|
|
3714
|
+
* once per call on EVERY outcome including the throw — after a half-failure
|
|
3715
|
+
* the draft row really is gone, and #4363's asymmetry decides it: an
|
|
3716
|
+
* unnecessary invalidation costs one refetch, a missed one costs the cache's
|
|
3717
|
+
* full 5-minute TTL of stale overrides.
|
|
3269
3718
|
*/
|
|
3270
3719
|
async deleteView(objectName, viewName) {
|
|
3271
3720
|
await this.connect();
|
|
3272
|
-
const
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3721
|
+
const metaClient = this.metadataClient();
|
|
3722
|
+
try {
|
|
3723
|
+
const draft = readViewDeleteReceipt(
|
|
3724
|
+
await metaClient.reset("view", viewName, { state: "draft" })
|
|
3725
|
+
);
|
|
3726
|
+
let published;
|
|
3727
|
+
try {
|
|
3728
|
+
published = readViewDeleteReceipt(await metaClient.reset("view", viewName));
|
|
3729
|
+
} catch (err) {
|
|
3730
|
+
const outcome = {
|
|
3731
|
+
deleted: false,
|
|
3732
|
+
draft,
|
|
3733
|
+
published: { removed: false }
|
|
3734
|
+
};
|
|
3735
|
+
throw Object.assign(
|
|
3736
|
+
new Error(
|
|
3737
|
+
`deleteView: view "${viewName}" on object "${objectName}" is NOT fully removed \u2014 the draft home was ${draft.removed ? "discarded" : "already absent"}, but deleting the published overlay failed. The published row is still serving the view; retry the delete.`
|
|
3738
|
+
),
|
|
3739
|
+
{ cause: err, outcome }
|
|
3740
|
+
);
|
|
3741
|
+
}
|
|
3742
|
+
return { deleted: draft.removed || published.removed, draft, published };
|
|
3743
|
+
} finally {
|
|
3744
|
+
this.invalidateViewKeys(objectName, viewName);
|
|
3745
|
+
}
|
|
3276
3746
|
}
|
|
3277
3747
|
/**
|
|
3278
3748
|
* Get an application definition by name or ID.
|
|
@@ -3296,6 +3766,51 @@ var ObjectStackAdapter = class {
|
|
|
3296
3766
|
return null;
|
|
3297
3767
|
}
|
|
3298
3768
|
}
|
|
3769
|
+
/**
|
|
3770
|
+
* Ask the by-name meta app route WHY an app is not in this session's app list
|
|
3771
|
+
* (objectui#4252).
|
|
3772
|
+
*
|
|
3773
|
+
* `GET /api/v1/meta/apps` is filtered per session server-side
|
|
3774
|
+
* (`filterAppForUser`), so an app withheld by `requiredPermissions` and an app
|
|
3775
|
+
* that does not exist are byte-identical there: both are simply absent. A
|
|
3776
|
+
* console reading only that list has one fact and two conditions, and it
|
|
3777
|
+
* renders its copy for the wrong one — "it may still be publishing" over a
|
|
3778
|
+
* permanent authorization decision, which cost a downstream acceptance round
|
|
3779
|
+
* two test batches spent chasing a platform defect that was a missing
|
|
3780
|
+
* permission-set binding.
|
|
3781
|
+
*
|
|
3782
|
+
* The maintainer ruling (2026-08-12) put the answer on the BY-NAME route
|
|
3783
|
+
* rather than in the list, so the enumeration surface is not widened past what
|
|
3784
|
+
* a by-name probe already implies (objectstack#8013 / PR #8135): an app that
|
|
3785
|
+
* exists and whose `requiredPermissions` the session lacks answers `403` with
|
|
3786
|
+
* `PERMISSION_DENIED` in the declared envelope, and absence — a nonexistent
|
|
3787
|
+
* name, an unpublished app, an app gated by an absent optional service —
|
|
3788
|
+
* keeps answering `404 RESOURCE_NOT_FOUND`.
|
|
3789
|
+
*
|
|
3790
|
+
* ## Why this is a separate method and not a flavour of {@link getApp}
|
|
3791
|
+
*
|
|
3792
|
+
* - `getApp` degrades EVERY failure to `null`, which is exactly the
|
|
3793
|
+
* conflation this exists to undo; changing it would silently re-point its
|
|
3794
|
+
* own callers' fallback-to-static-config path.
|
|
3795
|
+
* - `getApp` memoises in `metadataCache`. A verdict about the CALLER must not
|
|
3796
|
+
* be cached beside a document about the APP — one grant, and a cached
|
|
3797
|
+
* denial outlives the session it described.
|
|
3798
|
+
*
|
|
3799
|
+
* Nothing here throws: a probe that cannot reach an answer returns `unknown`
|
|
3800
|
+
* and the caller keeps whatever it was already showing. Only the measured
|
|
3801
|
+
* `code` produces `denied` — never a status, never a message (objectui#4408).
|
|
3802
|
+
*
|
|
3803
|
+
* @param appName - the app name as it appears in the URL segment
|
|
3804
|
+
*/
|
|
3805
|
+
async probeAppAccess(appName) {
|
|
3806
|
+
if (!appName) return "unknown";
|
|
3807
|
+
try {
|
|
3808
|
+
await this.client.meta.getItem("app", appName);
|
|
3809
|
+
return "granted";
|
|
3810
|
+
} catch (err) {
|
|
3811
|
+
return isAppPermissionDeniedError(err) ? "denied" : "unknown";
|
|
3812
|
+
}
|
|
3813
|
+
}
|
|
3299
3814
|
/**
|
|
3300
3815
|
* Get a page definition from ObjectStack.
|
|
3301
3816
|
* Uses the metadata API to fetch page layouts.
|
|
@@ -3448,7 +3963,17 @@ var ObjectStackAdapter = class {
|
|
|
3448
3963
|
* silently returning wrong numbers.
|
|
3449
3964
|
*
|
|
3450
3965
|
* @param dataset - An inline dataset definition (draft) OR a saved dataset name.
|
|
3451
|
-
* @param selection -
|
|
3966
|
+
* @param selection - The spec's {@link DatasetSelection} — dimension/measure
|
|
3967
|
+
* names to project plus runtime directives. This parameter IS the spec type
|
|
3968
|
+
* by reference, never a local restatement of it (objectui#3613): a hand
|
|
3969
|
+
* copy of a contract is a second dialect of it, and the copy this replaced
|
|
3970
|
+
* had already drifted three ways from `@objectstack/spec` — it required
|
|
3971
|
+
* `compareTo.dimension` (optional since objectstack#5011, and resolved by
|
|
3972
|
+
* the EXECUTOR, so requiring it pushed callers into exactly the
|
|
3973
|
+
* consumer-side dimension guess AGENTS.md #0.1 forbids), it widened
|
|
3974
|
+
* `timeDimensions` to `unknown[]` and `runtimeFilter` to
|
|
3975
|
+
* `Record<string, unknown>`, and it had never grown `dateGranularity` at
|
|
3976
|
+
* all. Pinned in `queryDataset.test.ts`.
|
|
3452
3977
|
*/
|
|
3453
3978
|
async queryDataset(dataset, selection) {
|
|
3454
3979
|
await this.connect();
|
|
@@ -3483,8 +4008,9 @@ var ObjectStackAdapter = class {
|
|
|
3483
4008
|
const object = typeof data?.object === "string" ? data.object : void 0;
|
|
3484
4009
|
const dimensionFields = data?.dimensionFields && typeof data.dimensionFields === "object" ? data.dimensionFields : void 0;
|
|
3485
4010
|
const drillRawRows = Array.isArray(data?.drillRawRows) ? data.drillRawRows : void 0;
|
|
4011
|
+
const drillRanges = Array.isArray(data?.drillRanges) ? data.drillRanges : void 0;
|
|
3486
4012
|
const totals = Array.isArray(data?.totals) ? data.totals : void 0;
|
|
3487
|
-
return { rows, fields, object, dimensionFields, drillRawRows, totals };
|
|
4013
|
+
return { rows, fields, object, dimensionFields, drillRawRows, drillRanges, totals };
|
|
3488
4014
|
}
|
|
3489
4015
|
/** Client-side aggregation fallback */
|
|
3490
4016
|
/**
|
|
@@ -3729,11 +4255,12 @@ function createObjectStackAdapter(config) {
|
|
|
3729
4255
|
}
|
|
3730
4256
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3731
4257
|
0 && (module.exports = {
|
|
4258
|
+
API_ACCESS_DENIED_CODES,
|
|
4259
|
+
APP_PERMISSION_DENIED_CODE,
|
|
3732
4260
|
AnalyticsNotInstalledError,
|
|
3733
4261
|
AnalyticsQueryRejectedError,
|
|
3734
4262
|
AuthenticationError,
|
|
3735
4263
|
BulkOperationError,
|
|
3736
|
-
CloudOperations,
|
|
3737
4264
|
ConcurrentUpdateError,
|
|
3738
4265
|
ConnectionError,
|
|
3739
4266
|
DataApiValidationError,
|
|
@@ -3752,17 +4279,23 @@ function createObjectStackAdapter(config) {
|
|
|
3752
4279
|
createErrorFromResponse,
|
|
3753
4280
|
createObjectStackAdapter,
|
|
3754
4281
|
createObjectStackUserStateAdapter,
|
|
4282
|
+
createQuietHttpLogger,
|
|
4283
|
+
formatHttpFailureMessage,
|
|
3755
4284
|
generateContractManifest,
|
|
3756
4285
|
getSharedDiscovery,
|
|
3757
4286
|
is404Error,
|
|
3758
4287
|
isAnalyticsNotInstalledError,
|
|
4288
|
+
isApiAccessDeniedError,
|
|
4289
|
+
isAppPermissionDeniedError,
|
|
3759
4290
|
isConcurrentUpdateError,
|
|
3760
4291
|
isErrorType,
|
|
3761
4292
|
isMalformedFilterError,
|
|
3762
4293
|
isObjectStackError,
|
|
3763
4294
|
normaliseClientError,
|
|
4295
|
+
readSaveAdvisories,
|
|
3764
4296
|
readTransactionalBatchCapability,
|
|
3765
4297
|
serializeOrderBy,
|
|
3766
4298
|
snapToGrid,
|
|
3767
|
-
validatePluginContract
|
|
4299
|
+
validatePluginContract,
|
|
4300
|
+
viewItemObjectName
|
|
3768
4301
|
});
|