@object-ui/data-objectstack 16.0.0 → 17.1.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 +711 -0
- package/README.md +63 -0
- package/dist/index.cjs +1425 -949
- package/dist/index.d.cts +319 -19
- package/dist/index.d.ts +319 -19
- package/dist/index.js +1420 -949
- package/package.json +5 -4
- package/src/aggregate-capability.test.ts +177 -0
- package/src/exportDownload.test.ts +51 -3
- package/src/filter-entry-translation.test.ts +360 -0
- package/src/filter-operator-ast-parity.test.ts +71 -0
- package/src/index.ts +984 -220
- package/src/integration.ts +8 -0
- package/src/listViews.test.ts +97 -0
- package/src/metadata-client.test.ts +8 -0
- package/src/metadata-client.ts +9 -0
- package/src/onMutation.test.ts +296 -31
- package/src/onWriteWarning.test.ts +172 -0
- package/src/orderby-serialization.test.ts +115 -0
- package/src/queryDataset.test.ts +50 -1
- package/src/searchAll.test.ts +120 -0
- package/src/userState.test.ts +25 -4
- package/src/userState.ts +10 -6
- package/src/validation-error.test.ts +94 -0
package/dist/index.cjs
CHANGED
|
@@ -22,12 +22,16 @@ 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
|
+
AnalyticsNotInstalledError: () => AnalyticsNotInstalledError,
|
|
26
|
+
AnalyticsQueryRejectedError: () => AnalyticsQueryRejectedError,
|
|
25
27
|
AuthenticationError: () => AuthenticationError,
|
|
26
28
|
BulkOperationError: () => BulkOperationError,
|
|
27
29
|
CloudOperations: () => CloudOperations,
|
|
28
30
|
ConcurrentUpdateError: () => ConcurrentUpdateError,
|
|
29
31
|
ConnectionError: () => ConnectionError,
|
|
32
|
+
FILTER_OPERATOR_ALIASES: () => FILTER_OPERATOR_ALIASES,
|
|
30
33
|
IntegrationManager: () => IntegrationManager,
|
|
34
|
+
MalformedFilterError: () => MalformedFilterError,
|
|
31
35
|
MetadataClient: () => MetadataClient,
|
|
32
36
|
MetadataNotFoundError: () => MetadataNotFoundError,
|
|
33
37
|
ObjectStackAdapter: () => ObjectStackAdapter,
|
|
@@ -35,6 +39,7 @@ __export(index_exports, {
|
|
|
35
39
|
SecurityManager: () => SecurityManager,
|
|
36
40
|
ValidationError: () => ValidationError,
|
|
37
41
|
calculateAutoLayout: () => calculateAutoLayout,
|
|
42
|
+
classifyAnalyticsFailure: () => classifyAnalyticsFailure,
|
|
38
43
|
clearSharedDiscoveryCache: () => clearSharedDiscoveryCache,
|
|
39
44
|
createDefaultCanvasConfig: () => createDefaultCanvasConfig,
|
|
40
45
|
createErrorFromResponse: () => createErrorFromResponse,
|
|
@@ -43,17 +48,42 @@ __export(index_exports, {
|
|
|
43
48
|
generateContractManifest: () => generateContractManifest,
|
|
44
49
|
getSharedDiscovery: () => getSharedDiscovery,
|
|
45
50
|
is404Error: () => is404Error,
|
|
51
|
+
isAnalyticsNotInstalledError: () => isAnalyticsNotInstalledError,
|
|
46
52
|
isConcurrentUpdateError: () => isConcurrentUpdateError,
|
|
47
53
|
isErrorType: () => isErrorType,
|
|
54
|
+
isMalformedFilterError: () => isMalformedFilterError,
|
|
48
55
|
isObjectStackError: () => isObjectStackError,
|
|
49
56
|
normaliseClientError: () => normaliseClientError,
|
|
57
|
+
readTransactionalBatchCapability: () => readTransactionalBatchCapability,
|
|
58
|
+
serializeOrderBy: () => serializeOrderBy,
|
|
50
59
|
snapToGrid: () => snapToGrid,
|
|
51
60
|
validatePluginContract: () => validatePluginContract
|
|
52
61
|
});
|
|
53
62
|
module.exports = __toCommonJS(index_exports);
|
|
54
63
|
var import_client = require("@objectstack/client");
|
|
55
64
|
|
|
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
|
+
|
|
56
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
|
+
};
|
|
57
87
|
function convertOperatorToAST(operator) {
|
|
58
88
|
const operatorMap = {
|
|
59
89
|
"$eq": "=",
|
|
@@ -83,11 +113,9 @@ function convertFiltersToAST(filter) {
|
|
|
83
113
|
if (typeof value === "object" && !Array.isArray(value)) {
|
|
84
114
|
for (const [operator, operatorValue] of Object.entries(value)) {
|
|
85
115
|
if (operator === "$regex") {
|
|
86
|
-
|
|
87
|
-
`[ObjectUI]
|
|
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.`
|
|
88
118
|
);
|
|
89
|
-
conditions.push([field, "contains", operatorValue]);
|
|
90
|
-
continue;
|
|
91
119
|
}
|
|
92
120
|
if (operator === "$null") {
|
|
93
121
|
conditions.push([field, operatorValue ? "is_null" : "is_not_null", true]);
|
|
@@ -101,7 +129,7 @@ function convertFiltersToAST(filter) {
|
|
|
101
129
|
if (astOperator) {
|
|
102
130
|
conditions.push([field, astOperator, operatorValue]);
|
|
103
131
|
} else {
|
|
104
|
-
throw new
|
|
132
|
+
throw new FilterOperatorError(
|
|
105
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.`
|
|
106
134
|
);
|
|
107
135
|
}
|
|
@@ -119,6 +147,72 @@ function convertFiltersToAST(filter) {
|
|
|
119
147
|
return ["and", ...conditions];
|
|
120
148
|
}
|
|
121
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
|
+
|
|
122
216
|
// ../core/src/utils/reference-keys.ts
|
|
123
217
|
function normalizeFieldReferenceKeys(fieldDef) {
|
|
124
218
|
if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
|
|
@@ -319,893 +413,896 @@ var MetadataCache = class {
|
|
|
319
413
|
}
|
|
320
414
|
};
|
|
321
415
|
|
|
322
|
-
// src/
|
|
323
|
-
var
|
|
416
|
+
// src/metadata-client.ts
|
|
417
|
+
var META_PREFIX = "/meta";
|
|
418
|
+
var API_PREFIX = "/api/v1";
|
|
419
|
+
function buildBase(config) {
|
|
420
|
+
const trimmed = config.baseUrl.replace(/\/+$/, "");
|
|
421
|
+
const scoped = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
|
|
422
|
+
if (/\/api\/v\d+$/i.test(trimmed)) {
|
|
423
|
+
const stripped = trimmed.replace(/\/api\/v\d+$/i, "");
|
|
424
|
+
const scopedNoApi = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
|
|
425
|
+
return `${stripped}${scopedNoApi}`;
|
|
426
|
+
}
|
|
427
|
+
return `${trimmed}${scoped}`;
|
|
428
|
+
}
|
|
429
|
+
async function parseError(res) {
|
|
430
|
+
let body;
|
|
431
|
+
try {
|
|
432
|
+
body = await res.json();
|
|
433
|
+
} catch {
|
|
434
|
+
body = await res.text().catch(() => void 0);
|
|
435
|
+
}
|
|
436
|
+
const bodyObj = body && typeof body === "object" ? body : void 0;
|
|
437
|
+
const errNode = bodyObj?.error;
|
|
438
|
+
const message = (errNode && typeof errNode === "object" ? errNode.message : errNode) ?? bodyObj?.message ?? `Metadata request failed: ${res.status} ${res.statusText}`;
|
|
439
|
+
const err = new Error(String(message));
|
|
440
|
+
err.status = res.status;
|
|
441
|
+
err.body = body;
|
|
442
|
+
const details = errNode && typeof errNode === "object" ? errNode.details : void 0;
|
|
443
|
+
const issues = details?.issues ?? (errNode && typeof errNode === "object" ? errNode.issues : void 0) ?? bodyObj?.issues;
|
|
444
|
+
if (Array.isArray(issues)) err.issues = issues;
|
|
445
|
+
const code = details?.code ?? (errNode && typeof errNode === "object" ? errNode.code : void 0) ?? bodyObj?.code;
|
|
446
|
+
if (code != null) err.code = String(code);
|
|
447
|
+
return err;
|
|
448
|
+
}
|
|
449
|
+
var MetadataClient = class _MetadataClient {
|
|
450
|
+
constructor(config) {
|
|
451
|
+
__publicField(this, "base");
|
|
452
|
+
__publicField(this, "fetchImpl");
|
|
453
|
+
__publicField(this, "headers");
|
|
454
|
+
/** ADR-0037: when true, reads render the draft-overlaid world. */
|
|
455
|
+
__publicField(this, "previewDrafts");
|
|
456
|
+
this.base = buildBase(config);
|
|
457
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
458
|
+
this.headers = { Accept: "application/json", ...config.headers ?? {} };
|
|
459
|
+
this.previewDrafts = config.previewDrafts === true;
|
|
460
|
+
}
|
|
461
|
+
/** Update the client's environment scope at runtime. */
|
|
462
|
+
withEnvironment(environmentId) {
|
|
463
|
+
return new _MetadataClient({
|
|
464
|
+
baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
|
|
465
|
+
environmentId,
|
|
466
|
+
fetch: this.fetchImpl,
|
|
467
|
+
headers: this.headers,
|
|
468
|
+
previewDrafts: this.previewDrafts
|
|
469
|
+
});
|
|
470
|
+
}
|
|
324
471
|
/**
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
* @param code - Unique error code for programmatic handling
|
|
329
|
-
* @param statusCode - Optional HTTP status code
|
|
330
|
-
* @param details - Optional additional error details for debugging
|
|
472
|
+
* Derive a client whose reads render the draft-overlaid world (or not).
|
|
473
|
+
* Same base/fetch/headers; used by the app-shell to switch the whole
|
|
474
|
+
* renderer tree into ADR-0037 preview mode off one URL flag.
|
|
331
475
|
*/
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
476
|
+
withPreviewDrafts(previewDrafts) {
|
|
477
|
+
if (previewDrafts === this.previewDrafts) return this;
|
|
478
|
+
const envMatch = this.base.match(/\/api\/v\d+\/environments\/([^/]+)\/meta$/);
|
|
479
|
+
return new _MetadataClient({
|
|
480
|
+
baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
|
|
481
|
+
...envMatch ? { environmentId: decodeURIComponent(envMatch[1]) } : {},
|
|
482
|
+
fetch: this.fetchImpl,
|
|
483
|
+
headers: this.headers,
|
|
484
|
+
previewDrafts
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
/** List all registered metadata types (returns the registry rows). */
|
|
488
|
+
async listTypes() {
|
|
489
|
+
const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
490
|
+
if (!res.ok) throw await parseError(res);
|
|
491
|
+
const data = await res.json();
|
|
492
|
+
if (Array.isArray(data)) return data;
|
|
493
|
+
if (data && Array.isArray(data.entries)) return data.entries;
|
|
494
|
+
if (data && Array.isArray(data.items)) return data.items;
|
|
495
|
+
if (data && Array.isArray(data.types)) {
|
|
496
|
+
return data.types.map((t) => ({ type: t }));
|
|
497
|
+
}
|
|
498
|
+
return [];
|
|
499
|
+
}
|
|
500
|
+
/** List items of a metadata type (e.g. `object`, `field`, `view`). */
|
|
501
|
+
async list(type, options = {}) {
|
|
502
|
+
const params = [];
|
|
503
|
+
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
504
|
+
if (this.previewDrafts) params.push("preview=draft");
|
|
505
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
506
|
+
const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
|
|
507
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
508
|
+
if (!res.ok) throw await parseError(res);
|
|
509
|
+
const data = await res.json();
|
|
510
|
+
if (Array.isArray(data)) return data;
|
|
511
|
+
if (data && Array.isArray(data.items)) {
|
|
512
|
+
return data.items;
|
|
340
513
|
}
|
|
514
|
+
return [];
|
|
341
515
|
}
|
|
342
516
|
/**
|
|
343
|
-
*
|
|
517
|
+
* List pending DRAFT items (ADR-0033) — what an AI authored but nobody
|
|
518
|
+
* published yet. `list()` only sees published/active metadata, so a
|
|
519
|
+
* just-built app package looks empty there; this surfaces the drafts so the
|
|
520
|
+
* console can show a "pending changes" view and draft-aware package contents.
|
|
521
|
+
* Optionally narrow by `packageId` and/or `type`. Returns light headers
|
|
522
|
+
* (no body) carrying `packageId` for grouping.
|
|
344
523
|
*/
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
var MetadataNotFoundError = class extends ObjectStackError {
|
|
357
|
-
constructor(objectName, details) {
|
|
358
|
-
super(
|
|
359
|
-
`Metadata not found for object: ${objectName}`,
|
|
360
|
-
"METADATA_NOT_FOUND",
|
|
361
|
-
404,
|
|
362
|
-
{ objectName, ...details }
|
|
363
|
-
);
|
|
364
|
-
this.name = "MetadataNotFoundError";
|
|
524
|
+
async listDrafts(options = {}) {
|
|
525
|
+
const params = [];
|
|
526
|
+
if (options.packageId) params.push(`packageId=${encodeURIComponent(options.packageId)}`);
|
|
527
|
+
if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
|
|
528
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
529
|
+
const url = `${this.base}/_drafts${qs}`;
|
|
530
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
531
|
+
if (!res.ok) throw await parseError(res);
|
|
532
|
+
const data = await res.json();
|
|
533
|
+
if (Array.isArray(data)) return data;
|
|
534
|
+
return data?.drafts ?? data?.data?.drafts ?? [];
|
|
365
535
|
}
|
|
366
|
-
};
|
|
367
|
-
var BulkOperationError = class extends ObjectStackError {
|
|
368
536
|
/**
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
* @param successCount - Number of successful operations
|
|
373
|
-
* @param failureCount - Number of failed operations
|
|
374
|
-
* @param errors - Array of individual errors
|
|
375
|
-
* @param details - Additional error details
|
|
537
|
+
* Get a single metadata item. Returns the unwrapped item content
|
|
538
|
+
* (matching the framework REST handler which calls `res.json(item)`).
|
|
539
|
+
* Returns `null` on 404 to keep the call-site ergonomic.
|
|
376
540
|
*/
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
);
|
|
390
|
-
__publicField(this, "successCount", successCount);
|
|
391
|
-
__publicField(this, "failureCount", failureCount);
|
|
392
|
-
__publicField(this, "errors", errors);
|
|
393
|
-
this.name = "BulkOperationError";
|
|
541
|
+
async get(type, name, options = {}) {
|
|
542
|
+
const params = [];
|
|
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("&")}` : "";
|
|
547
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
548
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
549
|
+
if (res.status === 404) return null;
|
|
550
|
+
if (!res.ok) throw await parseError(res);
|
|
551
|
+
return await res.json();
|
|
394
552
|
}
|
|
395
553
|
/**
|
|
396
|
-
*
|
|
554
|
+
* Read the pending draft body for an item (`?state=draft`). Returns
|
|
555
|
+
* `null` when there is no draft pending. Draft reads do NOT fall
|
|
556
|
+
* back to the published overlay or the artifact registry — a `null`
|
|
557
|
+
* unambiguously means "nothing to publish".
|
|
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.
|
|
397
564
|
*/
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const failureRate = total > 0 ? this.failureCount / total : 0;
|
|
401
|
-
return {
|
|
402
|
-
operation: this.details?.operation,
|
|
403
|
-
total,
|
|
404
|
-
successful: this.successCount,
|
|
405
|
-
failed: this.failureCount,
|
|
406
|
-
failureRate,
|
|
407
|
-
errors: this.errors
|
|
408
|
-
};
|
|
565
|
+
async getDraft(type, name, options = {}) {
|
|
566
|
+
return this.get(type, name, { state: "draft", ...options.packageId ? { packageId: options.packageId } : {} });
|
|
409
567
|
}
|
|
410
|
-
};
|
|
411
|
-
var ConnectionError = class extends ObjectStackError {
|
|
412
|
-
constructor(message, url, details, statusCode) {
|
|
413
|
-
super(
|
|
414
|
-
`Connection error: ${message}`,
|
|
415
|
-
"CONNECTION_ERROR",
|
|
416
|
-
statusCode || 503,
|
|
417
|
-
{ url, ...details }
|
|
418
|
-
);
|
|
419
|
-
__publicField(this, "url", url);
|
|
420
|
-
this.name = "ConnectionError";
|
|
421
|
-
}
|
|
422
|
-
};
|
|
423
|
-
var AuthenticationError = class extends ObjectStackError {
|
|
424
|
-
constructor(message = "Authentication failed", details, statusCode) {
|
|
425
|
-
super(
|
|
426
|
-
message,
|
|
427
|
-
"AUTHENTICATION_ERROR",
|
|
428
|
-
statusCode || 401,
|
|
429
|
-
details
|
|
430
|
-
);
|
|
431
|
-
this.name = "AuthenticationError";
|
|
432
|
-
}
|
|
433
|
-
};
|
|
434
|
-
var ValidationError = class extends ObjectStackError {
|
|
435
568
|
/**
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
* @param validationErrors - Array of validation error details
|
|
441
|
-
* @param details - Additional error details
|
|
569
|
+
* Save (PUT) a metadata item. The framework accepts both the bare
|
|
570
|
+
* item payload and the `{ item: ... }` / `{ metadata: ... }`
|
|
571
|
+
* envelopes; we send bare for consistency. Pass `mode: 'draft'` to
|
|
572
|
+
* stage the change without publishing (Studio's "Save" button).
|
|
442
573
|
*/
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
);
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
574
|
+
async save(type, name, item, options = {}) {
|
|
575
|
+
if (!name || !String(name).trim()) {
|
|
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}`;
|
|
586
|
+
const headers = {
|
|
587
|
+
...this.headers,
|
|
588
|
+
"Content-Type": "application/json"
|
|
589
|
+
};
|
|
590
|
+
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
591
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
592
|
+
const res = await this.fetchImpl(url, {
|
|
593
|
+
method: "PUT",
|
|
594
|
+
headers,
|
|
595
|
+
body: JSON.stringify(item)
|
|
596
|
+
});
|
|
597
|
+
if (!res.ok) throw await parseError(res);
|
|
598
|
+
return await res.json();
|
|
457
599
|
}
|
|
458
600
|
/**
|
|
459
|
-
*
|
|
601
|
+
* Publish a single pending draft BY REFERENCE — promotes the draft row for
|
|
602
|
+
* `(type, name)` into the active overlay and drops the draft. Works for ANY
|
|
603
|
+
* draft, including ones with no `packageId` binding (which the package-scoped
|
|
604
|
+
* `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
|
|
605
|
+
* exact set returned by {@link listDrafts} without needing a package.
|
|
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.
|
|
460
611
|
*/
|
|
461
|
-
|
|
462
|
-
|
|
612
|
+
async publishDraft(type, name) {
|
|
613
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
|
|
614
|
+
const res = await this.fetchImpl(url, {
|
|
615
|
+
method: "POST",
|
|
616
|
+
headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
|
|
617
|
+
body: "{}"
|
|
618
|
+
});
|
|
619
|
+
if (!res.ok) throw await parseError(res);
|
|
620
|
+
const body = await res.json().catch(() => ({}));
|
|
621
|
+
const inner = body?.data && typeof body.data === "object" ? body.data : body;
|
|
622
|
+
return inner;
|
|
463
623
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
624
|
+
/**
|
|
625
|
+
* Get the 3-state layered view of a metadata item (Phase 3a). Returns
|
|
626
|
+
* `code` (the artifact / fallback default), `overlay` (the saved
|
|
627
|
+
* customisation, if any), and `effective` (what the runtime sees).
|
|
628
|
+
*/
|
|
629
|
+
async layered(type, name, options = {}) {
|
|
630
|
+
const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : "";
|
|
631
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
|
|
632
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
633
|
+
if (res.status === 404) {
|
|
634
|
+
return { code: null, overlay: null, overlayScope: null, effective: null };
|
|
474
635
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
505
|
-
function isErrorType(error, errorClass) {
|
|
506
|
-
return error instanceof errorClass;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// src/cloud.ts
|
|
510
|
-
var CloudOperations = class {
|
|
511
|
-
constructor(getClient) {
|
|
512
|
-
__publicField(this, "getClient", getClient);
|
|
636
|
+
if (!res.ok) throw await parseError(res);
|
|
637
|
+
const body = await res.json();
|
|
638
|
+
const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
|
|
639
|
+
if (!hasEnvelope) {
|
|
640
|
+
return {
|
|
641
|
+
code: null,
|
|
642
|
+
overlay: null,
|
|
643
|
+
overlayScope: null,
|
|
644
|
+
effective: body
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
code: body.code ?? null,
|
|
649
|
+
overlay: body.overlay ?? null,
|
|
650
|
+
overlayScope: body.overlayScope ?? null,
|
|
651
|
+
effective: body.effective ?? null,
|
|
652
|
+
...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
|
|
653
|
+
// ADR-0010 Phase 4 — pass through the metadata-protection envelope so
|
|
654
|
+
// the editor can render lock affordances without a second round trip.
|
|
655
|
+
...body.lock !== void 0 ? { lock: body.lock } : {},
|
|
656
|
+
...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
|
|
657
|
+
...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
|
|
658
|
+
...body.lockDocsUrl !== void 0 ? { lockDocsUrl: body.lockDocsUrl } : {},
|
|
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
|
+
};
|
|
513
666
|
}
|
|
514
667
|
/**
|
|
515
|
-
*
|
|
668
|
+
* Cross-type sweep of load-time validation results — calls
|
|
669
|
+
* `GET /meta/diagnostics`. Returns every entry the framework
|
|
670
|
+
* considers invalid (or, when `severity: 'warning'`, also entries
|
|
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.
|
|
516
675
|
*/
|
|
517
|
-
async
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
676
|
+
async diagnostics(options = {}) {
|
|
677
|
+
const params = [];
|
|
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
|
+
};
|
|
521
696
|
}
|
|
522
697
|
/**
|
|
523
|
-
*
|
|
698
|
+
* Find every metadata item that references this one (Phase 3a). Useful
|
|
699
|
+
* for pre-delete impact analysis: "Are any views pointing at this
|
|
700
|
+
* object before I drop it?".
|
|
524
701
|
*/
|
|
525
|
-
async
|
|
526
|
-
const
|
|
527
|
-
const
|
|
528
|
-
|
|
702
|
+
async references(type, name) {
|
|
703
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
|
|
704
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
705
|
+
if (res.status === 404) return [];
|
|
706
|
+
if (!res.ok) throw await parseError(res);
|
|
707
|
+
const data = await res.json();
|
|
708
|
+
return Array.isArray(data) ? data : data?.items ?? [];
|
|
529
709
|
}
|
|
530
710
|
/**
|
|
531
|
-
*
|
|
711
|
+
* Reset a metadata customization overlay back to the artifact default.
|
|
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.
|
|
532
715
|
*/
|
|
533
|
-
async
|
|
534
|
-
const
|
|
535
|
-
const
|
|
536
|
-
|
|
716
|
+
async reset(type, name, options = {}) {
|
|
717
|
+
const qs = options.state === "draft" ? "?state=draft" : "";
|
|
718
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
719
|
+
const headers = { ...this.headers };
|
|
720
|
+
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
721
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
722
|
+
const res = await this.fetchImpl(url, { method: "DELETE", headers });
|
|
723
|
+
if (!res.ok) throw await parseError(res);
|
|
724
|
+
return await res.json();
|
|
537
725
|
}
|
|
538
726
|
/**
|
|
539
|
-
*
|
|
727
|
+
* Promote the pending draft of an item to the active overlay
|
|
728
|
+
* (POST `/meta/:type/:name/publish`). Returns
|
|
729
|
+
* `{ success, version, seq, message }`. Throws a `404 no_draft` if
|
|
730
|
+
* nothing is pending and `409 metadata_conflict` if the published
|
|
731
|
+
* overlay moved while the draft was sitting.
|
|
540
732
|
*/
|
|
541
|
-
async
|
|
542
|
-
const
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
};
|
|
547
|
-
|
|
548
|
-
// src/contracts.ts
|
|
549
|
-
function validatePluginContract(contract) {
|
|
550
|
-
const errors = [];
|
|
551
|
-
const warnings = [];
|
|
552
|
-
if (!contract.name || contract.name.trim().length === 0) {
|
|
553
|
-
errors.push({ field: "name", message: "Plugin name is required", code: "MISSING_NAME" });
|
|
554
|
-
}
|
|
555
|
-
if (!contract.version || !/^\d+\.\d+\.\d+/.test(contract.version)) {
|
|
556
|
-
errors.push({ field: "version", message: "Valid semver version is required", code: "INVALID_VERSION" });
|
|
557
|
-
}
|
|
558
|
-
if (!contract.exports || contract.exports.length === 0) {
|
|
559
|
-
errors.push({ field: "exports", message: "At least one export is required", code: "NO_EXPORTS" });
|
|
560
|
-
}
|
|
561
|
-
if (contract.exports) {
|
|
562
|
-
const validTypes = ["component", "hook", "utility", "provider"];
|
|
563
|
-
for (const exp of contract.exports) {
|
|
564
|
-
if (!exp.name) {
|
|
565
|
-
errors.push({ field: "exports.name", message: "Export name is required", code: "MISSING_EXPORT_NAME" });
|
|
566
|
-
}
|
|
567
|
-
if (!validTypes.includes(exp.type)) {
|
|
568
|
-
errors.push({ field: "exports.type", message: `Invalid export type: ${exp.type}`, code: "INVALID_EXPORT_TYPE" });
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
if (!contract.permissions || contract.permissions.length === 0) {
|
|
573
|
-
warnings.push("No permissions declared \u2014 plugin will have minimal access");
|
|
574
|
-
}
|
|
575
|
-
return { valid: errors.length === 0, errors, warnings };
|
|
576
|
-
}
|
|
577
|
-
function generateContractManifest(contract) {
|
|
578
|
-
return {
|
|
579
|
-
$schema: "https://objectui.org/schemas/plugin-contract-v1.json",
|
|
580
|
-
name: contract.name,
|
|
581
|
-
version: contract.version,
|
|
582
|
-
peerDependencies: contract.peerDependencies ?? {},
|
|
583
|
-
exports: contract.exports.map((exp) => ({
|
|
584
|
-
name: exp.name,
|
|
585
|
-
type: exp.type,
|
|
586
|
-
description: exp.description ?? ""
|
|
587
|
-
})),
|
|
588
|
-
permissions: contract.permissions ?? [],
|
|
589
|
-
api: contract.api ?? {},
|
|
590
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
591
|
-
};
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// src/userState.ts
|
|
595
|
-
var DEFAULT_RESOURCE = "sys_user_preference";
|
|
596
|
-
function createObjectStackUserStateAdapter(options) {
|
|
597
|
-
const {
|
|
598
|
-
dataSource,
|
|
599
|
-
userId,
|
|
600
|
-
key,
|
|
601
|
-
resource = DEFAULT_RESOURCE,
|
|
602
|
-
onError = () => {
|
|
603
|
-
}
|
|
604
|
-
} = options;
|
|
605
|
-
let cachedRowId = null;
|
|
606
|
-
let saveChain = Promise.resolve();
|
|
607
|
-
const findExisting = async () => {
|
|
608
|
-
const params = {
|
|
609
|
-
$filter: {
|
|
610
|
-
user_id: userId,
|
|
611
|
-
key
|
|
612
|
-
},
|
|
613
|
-
$top: 1
|
|
733
|
+
async publish(type, name, options = {}) {
|
|
734
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
|
|
735
|
+
const headers = {
|
|
736
|
+
...this.headers,
|
|
737
|
+
"Content-Type": "application/json"
|
|
614
738
|
};
|
|
615
|
-
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const keyMismatch = r.key !== void 0 && r.key !== key;
|
|
621
|
-
return !userMismatch && !keyMismatch;
|
|
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 } : {})
|
|
622
744
|
});
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
const decodeValue = (value) => {
|
|
626
|
-
if (Array.isArray(value)) return value;
|
|
627
|
-
if (typeof value === "string") {
|
|
628
|
-
try {
|
|
629
|
-
const parsed = JSON.parse(value);
|
|
630
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
631
|
-
} catch {
|
|
632
|
-
return [];
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
return [];
|
|
636
|
-
};
|
|
637
|
-
const upsert = async (items) => {
|
|
638
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
639
|
-
if (cachedRowId !== null) {
|
|
640
|
-
try {
|
|
641
|
-
await dataSource.update(resource, cachedRowId, { value: items, updated_at: now });
|
|
642
|
-
return;
|
|
643
|
-
} catch (updateError) {
|
|
644
|
-
cachedRowId = null;
|
|
645
|
-
onError("save", updateError);
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
const existing = await findExisting();
|
|
649
|
-
if (existing && existing.id !== void 0 && existing.id !== null) {
|
|
650
|
-
cachedRowId = existing.id;
|
|
651
|
-
await dataSource.update(resource, existing.id, { value: items, updated_at: now });
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
try {
|
|
655
|
-
const created = await dataSource.create(resource, {
|
|
656
|
-
user_id: userId,
|
|
657
|
-
key,
|
|
658
|
-
value: items,
|
|
659
|
-
updated_at: now
|
|
660
|
-
});
|
|
661
|
-
const newId = created?.id;
|
|
662
|
-
if (newId !== void 0 && newId !== null) cachedRowId = newId;
|
|
663
|
-
} catch (createError) {
|
|
664
|
-
const recovered = await findExisting();
|
|
665
|
-
if (recovered && recovered.id !== void 0 && recovered.id !== null) {
|
|
666
|
-
cachedRowId = recovered.id;
|
|
667
|
-
await dataSource.update(resource, recovered.id, { value: items, updated_at: now });
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
|
-
throw createError;
|
|
671
|
-
}
|
|
672
|
-
};
|
|
673
|
-
return {
|
|
674
|
-
async load() {
|
|
675
|
-
try {
|
|
676
|
-
const row = await findExisting();
|
|
677
|
-
if (!row) {
|
|
678
|
-
cachedRowId = null;
|
|
679
|
-
return [];
|
|
680
|
-
}
|
|
681
|
-
if (row.id !== void 0 && row.id !== null) cachedRowId = row.id;
|
|
682
|
-
return decodeValue(row.value);
|
|
683
|
-
} catch (error) {
|
|
684
|
-
onError("load", error);
|
|
685
|
-
return [];
|
|
686
|
-
}
|
|
687
|
-
},
|
|
688
|
-
async save(items) {
|
|
689
|
-
const run = saveChain.then(() => upsert(items)).catch((error) => {
|
|
690
|
-
onError("save", error);
|
|
691
|
-
});
|
|
692
|
-
saveChain = run;
|
|
693
|
-
return run;
|
|
694
|
-
}
|
|
695
|
-
};
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// src/integration.ts
|
|
699
|
-
var IntegrationManager = class {
|
|
700
|
-
constructor() {
|
|
701
|
-
__publicField(this, "integrations", /* @__PURE__ */ new Map());
|
|
745
|
+
if (!res.ok) throw await parseError(res);
|
|
746
|
+
return await res.json();
|
|
702
747
|
}
|
|
703
748
|
/**
|
|
704
|
-
*
|
|
749
|
+
* Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
|
|
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.
|
|
705
755
|
*/
|
|
706
|
-
|
|
707
|
-
this.
|
|
756
|
+
async rollback(type, name, toVersion, options = {}) {
|
|
757
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
|
|
758
|
+
const headers = {
|
|
759
|
+
...this.headers,
|
|
760
|
+
"Content-Type": "application/json"
|
|
761
|
+
};
|
|
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();
|
|
708
773
|
}
|
|
709
774
|
/**
|
|
710
|
-
*
|
|
775
|
+
* Compute a structured top-level key diff between two history versions
|
|
776
|
+
* (GET `/meta/:type/:name/diff?from=&to=`). Returns
|
|
777
|
+
* `{ added, removed, changed }` where each entry carries a `path` and
|
|
778
|
+
* the relevant value(s). Useful for "what changed in this draft?" and
|
|
779
|
+
* pre-rollback previews.
|
|
711
780
|
*/
|
|
712
|
-
|
|
713
|
-
|
|
781
|
+
async diff(type, name, fromVersion, toVersion) {
|
|
782
|
+
const params = new URLSearchParams();
|
|
783
|
+
if (fromVersion !== void 0) params.set("from", String(fromVersion));
|
|
784
|
+
if (toVersion !== void 0) params.set("to", String(toVersion));
|
|
785
|
+
const qs = params.toString();
|
|
786
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ""}`;
|
|
787
|
+
const res = await this.fetchImpl(url, {
|
|
788
|
+
method: "GET",
|
|
789
|
+
headers: this.headers,
|
|
790
|
+
cache: "no-store"
|
|
791
|
+
});
|
|
792
|
+
if (!res.ok) throw await parseError(res);
|
|
793
|
+
return await res.json();
|
|
714
794
|
}
|
|
715
|
-
/**
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
795
|
+
/** Fetch the durable history (change log) for one metadata item. */
|
|
796
|
+
async history(type, name, options = {}) {
|
|
797
|
+
const params = new URLSearchParams();
|
|
798
|
+
if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
|
|
799
|
+
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
800
|
+
const qs = params.toString();
|
|
801
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
|
|
802
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
803
|
+
if (!res.ok) throw await parseError(res);
|
|
804
|
+
return await res.json();
|
|
720
805
|
}
|
|
721
806
|
/**
|
|
722
|
-
*
|
|
807
|
+
* Fetch the protection-audit trail for one metadata item
|
|
808
|
+
* (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
|
|
809
|
+
* delete/reset attempt — allowed, denied, or forced — so the
|
|
810
|
+
* Studio "审计日志 / Audit log" tab can render who tried what
|
|
811
|
+
* and whether a lock blocked it.
|
|
723
812
|
*/
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
);
|
|
813
|
+
async audit(type, name, options = {}) {
|
|
814
|
+
const params = new URLSearchParams();
|
|
815
|
+
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
816
|
+
const qs = params.toString();
|
|
817
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ""}`;
|
|
818
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
819
|
+
if (!res.ok) throw await parseError(res);
|
|
820
|
+
return await res.json();
|
|
728
821
|
}
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
// src/errors.ts
|
|
825
|
+
var ObjectStackError = class extends Error {
|
|
729
826
|
/**
|
|
730
|
-
*
|
|
731
|
-
*
|
|
827
|
+
* Create a new ObjectStackError
|
|
828
|
+
*
|
|
829
|
+
* @param message - Human-readable error message
|
|
830
|
+
* @param code - Unique error code for programmatic handling
|
|
831
|
+
* @param statusCode - Optional HTTP status code
|
|
832
|
+
* @param details - Optional additional error details for debugging
|
|
732
833
|
*/
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
} catch (err) {
|
|
742
|
-
results.push({ id, success: false, error: err.message });
|
|
743
|
-
}
|
|
834
|
+
constructor(message, code, statusCode, details) {
|
|
835
|
+
super(message);
|
|
836
|
+
__publicField(this, "code", code);
|
|
837
|
+
__publicField(this, "statusCode", statusCode);
|
|
838
|
+
__publicField(this, "details", details);
|
|
839
|
+
this.name = "ObjectStackError";
|
|
840
|
+
if (Error.captureStackTrace) {
|
|
841
|
+
Error.captureStackTrace(this, this.constructor);
|
|
744
842
|
}
|
|
745
|
-
return results;
|
|
746
843
|
}
|
|
747
844
|
/**
|
|
748
|
-
*
|
|
845
|
+
* Convert error to JSON for logging/debugging
|
|
749
846
|
*/
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
}
|
|
760
|
-
} catch (e) {
|
|
761
|
-
if (e instanceof TypeError) {
|
|
762
|
-
throw new Error(`Invalid webhook URL: ${url}`);
|
|
763
|
-
}
|
|
764
|
-
throw e;
|
|
765
|
-
}
|
|
766
|
-
await fetch(url, {
|
|
767
|
-
method: cfg.method,
|
|
768
|
-
headers: {
|
|
769
|
-
"Content-Type": "application/json",
|
|
770
|
-
...cfg.headers
|
|
771
|
-
},
|
|
772
|
-
body: JSON.stringify(payload)
|
|
773
|
-
});
|
|
774
|
-
break;
|
|
775
|
-
}
|
|
776
|
-
case "slack": {
|
|
777
|
-
const cfg = integration.config;
|
|
778
|
-
const url = cfg.webhookUrl;
|
|
779
|
-
try {
|
|
780
|
-
const parsed = new URL(url);
|
|
781
|
-
if (parsed.protocol !== "https:") {
|
|
782
|
-
throw new Error(`Invalid Slack webhook URL protocol: ${parsed.protocol}`);
|
|
783
|
-
}
|
|
784
|
-
} catch (e) {
|
|
785
|
-
if (e instanceof TypeError) {
|
|
786
|
-
throw new Error(`Invalid Slack webhook URL: ${url}`);
|
|
787
|
-
}
|
|
788
|
-
throw e;
|
|
789
|
-
}
|
|
790
|
-
await fetch(url, {
|
|
791
|
-
method: "POST",
|
|
792
|
-
headers: { "Content-Type": "application/json" },
|
|
793
|
-
body: JSON.stringify({
|
|
794
|
-
channel: cfg.channel,
|
|
795
|
-
username: cfg.username,
|
|
796
|
-
icon_emoji: cfg.iconEmoji,
|
|
797
|
-
text: JSON.stringify(payload)
|
|
798
|
-
})
|
|
799
|
-
});
|
|
800
|
-
break;
|
|
801
|
-
}
|
|
802
|
-
// Email and other providers would require server-side implementation
|
|
803
|
-
default:
|
|
804
|
-
break;
|
|
805
|
-
}
|
|
847
|
+
toJSON() {
|
|
848
|
+
return {
|
|
849
|
+
name: this.name,
|
|
850
|
+
message: this.message,
|
|
851
|
+
code: this.code,
|
|
852
|
+
statusCode: this.statusCode,
|
|
853
|
+
details: this.details,
|
|
854
|
+
stack: this.stack
|
|
855
|
+
};
|
|
806
856
|
}
|
|
807
857
|
};
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
const scopedNoApi = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
|
|
818
|
-
return `${stripped}${scopedNoApi}`;
|
|
819
|
-
}
|
|
820
|
-
return `${trimmed}${scoped}`;
|
|
821
|
-
}
|
|
822
|
-
async function parseError(res) {
|
|
823
|
-
let body;
|
|
824
|
-
try {
|
|
825
|
-
body = await res.json();
|
|
826
|
-
} catch {
|
|
827
|
-
body = await res.text().catch(() => void 0);
|
|
828
|
-
}
|
|
829
|
-
const bodyObj = body && typeof body === "object" ? body : void 0;
|
|
830
|
-
const errNode = bodyObj?.error;
|
|
831
|
-
const message = (errNode && typeof errNode === "object" ? errNode.message : errNode) ?? bodyObj?.message ?? `Metadata request failed: ${res.status} ${res.statusText}`;
|
|
832
|
-
const err = new Error(String(message));
|
|
833
|
-
err.status = res.status;
|
|
834
|
-
err.body = body;
|
|
835
|
-
const details = errNode && typeof errNode === "object" ? errNode.details : void 0;
|
|
836
|
-
const issues = details?.issues ?? (errNode && typeof errNode === "object" ? errNode.issues : void 0) ?? bodyObj?.issues;
|
|
837
|
-
if (Array.isArray(issues)) err.issues = issues;
|
|
838
|
-
const code = details?.code ?? (errNode && typeof errNode === "object" ? errNode.code : void 0) ?? bodyObj?.code;
|
|
839
|
-
if (code != null) err.code = String(code);
|
|
840
|
-
return err;
|
|
841
|
-
}
|
|
842
|
-
var MetadataClient = class _MetadataClient {
|
|
843
|
-
constructor(config) {
|
|
844
|
-
__publicField(this, "base");
|
|
845
|
-
__publicField(this, "fetchImpl");
|
|
846
|
-
__publicField(this, "headers");
|
|
847
|
-
/** ADR-0037: when true, reads render the draft-overlaid world. */
|
|
848
|
-
__publicField(this, "previewDrafts");
|
|
849
|
-
this.base = buildBase(config);
|
|
850
|
-
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
851
|
-
this.headers = { Accept: "application/json", ...config.headers ?? {} };
|
|
852
|
-
this.previewDrafts = config.previewDrafts === true;
|
|
858
|
+
var MetadataNotFoundError = class extends ObjectStackError {
|
|
859
|
+
constructor(objectName, details) {
|
|
860
|
+
super(
|
|
861
|
+
`Metadata not found for object: ${objectName}`,
|
|
862
|
+
"METADATA_NOT_FOUND",
|
|
863
|
+
404,
|
|
864
|
+
{ objectName, ...details }
|
|
865
|
+
);
|
|
866
|
+
this.name = "MetadataNotFoundError";
|
|
853
867
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
868
|
+
};
|
|
869
|
+
var BulkOperationError = class extends ObjectStackError {
|
|
870
|
+
/**
|
|
871
|
+
* Create a new BulkOperationError
|
|
872
|
+
*
|
|
873
|
+
* @param operation - The bulk operation that failed (create, update, delete)
|
|
874
|
+
* @param successCount - Number of successful operations
|
|
875
|
+
* @param failureCount - Number of failed operations
|
|
876
|
+
* @param errors - Array of individual errors
|
|
877
|
+
* @param details - Additional error details
|
|
878
|
+
*/
|
|
879
|
+
constructor(operation, successCount, failureCount, errors, details) {
|
|
880
|
+
super(
|
|
881
|
+
`Bulk ${operation} operation failed: ${successCount} succeeded, ${failureCount} failed`,
|
|
882
|
+
"BULK_OPERATION_ERROR",
|
|
883
|
+
500,
|
|
884
|
+
{
|
|
885
|
+
operation,
|
|
886
|
+
successCount,
|
|
887
|
+
failureCount,
|
|
888
|
+
errors,
|
|
889
|
+
...details
|
|
890
|
+
}
|
|
891
|
+
);
|
|
892
|
+
__publicField(this, "successCount", successCount);
|
|
893
|
+
__publicField(this, "failureCount", failureCount);
|
|
894
|
+
__publicField(this, "errors", errors);
|
|
895
|
+
this.name = "BulkOperationError";
|
|
863
896
|
}
|
|
864
897
|
/**
|
|
865
|
-
*
|
|
866
|
-
* Same base/fetch/headers; used by the app-shell to switch the whole
|
|
867
|
-
* renderer tree into ADR-0037 preview mode off one URL flag.
|
|
898
|
+
* Get a summary of the bulk operation failure
|
|
868
899
|
*/
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
const
|
|
872
|
-
return
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
900
|
+
getSummary() {
|
|
901
|
+
const total = this.successCount + this.failureCount;
|
|
902
|
+
const failureRate = total > 0 ? this.failureCount / total : 0;
|
|
903
|
+
return {
|
|
904
|
+
operation: this.details?.operation,
|
|
905
|
+
total,
|
|
906
|
+
successful: this.successCount,
|
|
907
|
+
failed: this.failureCount,
|
|
908
|
+
failureRate,
|
|
909
|
+
errors: this.errors
|
|
910
|
+
};
|
|
879
911
|
}
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
return [];
|
|
912
|
+
};
|
|
913
|
+
var ConnectionError = class extends ObjectStackError {
|
|
914
|
+
constructor(message, url, details, statusCode) {
|
|
915
|
+
super(
|
|
916
|
+
`Connection error: ${message}`,
|
|
917
|
+
"CONNECTION_ERROR",
|
|
918
|
+
statusCode || 503,
|
|
919
|
+
{ url, ...details }
|
|
920
|
+
);
|
|
921
|
+
__publicField(this, "url", url);
|
|
922
|
+
this.name = "ConnectionError";
|
|
892
923
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
if (Array.isArray(data)) return data;
|
|
904
|
-
if (data && Array.isArray(data.items)) {
|
|
905
|
-
return data.items;
|
|
906
|
-
}
|
|
907
|
-
return [];
|
|
924
|
+
};
|
|
925
|
+
var AuthenticationError = class extends ObjectStackError {
|
|
926
|
+
constructor(message = "Authentication failed", details, statusCode) {
|
|
927
|
+
super(
|
|
928
|
+
message,
|
|
929
|
+
"AUTHENTICATION_ERROR",
|
|
930
|
+
statusCode || 401,
|
|
931
|
+
details
|
|
932
|
+
);
|
|
933
|
+
this.name = "AuthenticationError";
|
|
908
934
|
}
|
|
935
|
+
};
|
|
936
|
+
var ValidationError = class extends ObjectStackError {
|
|
909
937
|
/**
|
|
910
|
-
*
|
|
911
|
-
*
|
|
912
|
-
*
|
|
913
|
-
*
|
|
914
|
-
*
|
|
915
|
-
*
|
|
938
|
+
* Create a new ValidationError
|
|
939
|
+
*
|
|
940
|
+
* @param message - Human-readable error message
|
|
941
|
+
* @param field - The field that failed validation (optional)
|
|
942
|
+
* @param validationErrors - Array of validation error details
|
|
943
|
+
* @param details - Additional error details
|
|
916
944
|
*/
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
945
|
+
constructor(message, field, validationErrors, details) {
|
|
946
|
+
super(
|
|
947
|
+
message,
|
|
948
|
+
"VALIDATION_ERROR",
|
|
949
|
+
400,
|
|
950
|
+
{
|
|
951
|
+
field,
|
|
952
|
+
validationErrors,
|
|
953
|
+
...details
|
|
954
|
+
}
|
|
955
|
+
);
|
|
956
|
+
__publicField(this, "field", field);
|
|
957
|
+
__publicField(this, "validationErrors", validationErrors);
|
|
958
|
+
this.name = "ValidationError";
|
|
928
959
|
}
|
|
929
960
|
/**
|
|
930
|
-
* Get
|
|
931
|
-
* (matching the framework REST handler which calls `res.json(item)`).
|
|
932
|
-
* Returns `null` on 404 to keep the call-site ergonomic.
|
|
961
|
+
* Get all validation errors as a formatted list
|
|
933
962
|
*/
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
963
|
+
getValidationErrors() {
|
|
964
|
+
return this.validationErrors || [];
|
|
965
|
+
}
|
|
966
|
+
};
|
|
967
|
+
function createErrorFromResponse(response, context) {
|
|
968
|
+
const status = response?.status || response?.statusCode || 500;
|
|
969
|
+
const message = response?.message || response?.statusText || "Unknown error";
|
|
970
|
+
const details = {
|
|
971
|
+
context,
|
|
972
|
+
response: {
|
|
973
|
+
status,
|
|
974
|
+
data: response?.data,
|
|
975
|
+
headers: response?.headers
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
switch (status) {
|
|
979
|
+
case 401:
|
|
980
|
+
return new AuthenticationError(message, details, 401);
|
|
981
|
+
case 403:
|
|
982
|
+
return new AuthenticationError(message, details, 403);
|
|
983
|
+
case 404:
|
|
984
|
+
if (context?.includes("metadata") || context?.includes("schema") || context?.includes("getObjectSchema")) {
|
|
985
|
+
const objectName = extractObjectName(context);
|
|
986
|
+
return new MetadataNotFoundError(objectName, details);
|
|
987
|
+
}
|
|
988
|
+
return new ObjectStackError(message, "NOT_FOUND", 404, details);
|
|
989
|
+
case 400:
|
|
990
|
+
return new ValidationError(message, void 0, response?.data?.errors, details);
|
|
991
|
+
case 503:
|
|
992
|
+
return new ConnectionError(message, response?.config?.url, details, 503);
|
|
993
|
+
case 504:
|
|
994
|
+
return new ConnectionError(message, response?.config?.url, details, 504);
|
|
995
|
+
default:
|
|
996
|
+
return new ObjectStackError(message, "UNKNOWN_ERROR", status, details);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
function extractObjectName(context) {
|
|
1000
|
+
if (!context) return "unknown";
|
|
1001
|
+
const match = context.match(/\(([^)]+)\)/);
|
|
1002
|
+
return match ? match[1] : "unknown";
|
|
1003
|
+
}
|
|
1004
|
+
function isObjectStackError(error) {
|
|
1005
|
+
return error instanceof ObjectStackError;
|
|
1006
|
+
}
|
|
1007
|
+
function isErrorType(error, errorClass) {
|
|
1008
|
+
return error instanceof errorClass;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// src/cloud.ts
|
|
1012
|
+
var CloudOperations = class {
|
|
1013
|
+
constructor(getClient) {
|
|
1014
|
+
__publicField(this, "getClient", getClient);
|
|
945
1015
|
}
|
|
946
1016
|
/**
|
|
947
|
-
*
|
|
948
|
-
* `null` when there is no draft pending. Draft reads do NOT fall
|
|
949
|
-
* back to the published overlay or the artifact registry — a `null`
|
|
950
|
-
* unambiguously means "nothing to publish".
|
|
951
|
-
*
|
|
952
|
-
* Note: the framework wraps draft responses in an envelope
|
|
953
|
-
* `{ type, name, item }` (matching `getMetaItem`); callers should
|
|
954
|
-
* read `.item` to get the body. The legacy `get()` returns the
|
|
955
|
-
* unwrapped body, so this method preserves that asymmetry by
|
|
956
|
-
* returning whatever the server sent.
|
|
1017
|
+
* Deploy an application to the cloud.
|
|
957
1018
|
*/
|
|
958
|
-
async
|
|
959
|
-
|
|
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" };
|
|
960
1023
|
}
|
|
961
1024
|
/**
|
|
962
|
-
*
|
|
963
|
-
* item payload and the `{ item: ... }` / `{ metadata: ... }`
|
|
964
|
-
* envelopes; we send bare for consistency. Pass `mode: 'draft'` to
|
|
965
|
-
* stage the change without publishing (Studio's "Save" button).
|
|
1025
|
+
* Get deployment status.
|
|
966
1026
|
*/
|
|
967
|
-
async
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
972
|
-
const qs = params.length ? `?${params.join("&")}` : "";
|
|
973
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
974
|
-
const headers = {
|
|
975
|
-
...this.headers,
|
|
976
|
-
"Content-Type": "application/json"
|
|
977
|
-
};
|
|
978
|
-
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
979
|
-
if (options.actor) headers["X-Actor"] = options.actor;
|
|
980
|
-
const res = await this.fetchImpl(url, {
|
|
981
|
-
method: "PUT",
|
|
982
|
-
headers,
|
|
983
|
-
body: JSON.stringify(item)
|
|
984
|
-
});
|
|
985
|
-
if (!res.ok) throw await parseError(res);
|
|
986
|
-
return await res.json();
|
|
1027
|
+
async getDeploymentStatus(deploymentId) {
|
|
1028
|
+
const client = this.getClient();
|
|
1029
|
+
const result = await client.cloud?.getDeployment?.(deploymentId);
|
|
1030
|
+
return result ?? { status: "unknown" };
|
|
987
1031
|
}
|
|
988
1032
|
/**
|
|
989
|
-
*
|
|
990
|
-
* `(type, name)` into the active overlay and drops the draft. Works for ANY
|
|
991
|
-
* draft, including ones with no `packageId` binding (which the package-scoped
|
|
992
|
-
* `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
|
|
993
|
-
* exact set returned by {@link listDrafts} without needing a package.
|
|
994
|
-
*
|
|
995
|
-
* Returns the server result. For `seed` drafts the protocol also materializes
|
|
996
|
-
* the rows and reports under `seedApplied` — a data problem never fails the
|
|
997
|
-
* publish, so callers should check `seedApplied?.success` and warn the user
|
|
998
|
-
* rather than assume the data went live.
|
|
1033
|
+
* Search marketplace entries.
|
|
999
1034
|
*/
|
|
1000
|
-
async
|
|
1001
|
-
const
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1004
|
-
headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
|
|
1005
|
-
body: "{}"
|
|
1006
|
-
});
|
|
1007
|
-
if (!res.ok) throw await parseError(res);
|
|
1008
|
-
const body = await res.json().catch(() => ({}));
|
|
1009
|
-
const inner = body?.data && typeof body.data === "object" ? body.data : body;
|
|
1010
|
-
return inner;
|
|
1035
|
+
async searchMarketplace(query, category) {
|
|
1036
|
+
const client = this.getClient();
|
|
1037
|
+
const result = await client.cloud?.marketplace?.search?.({ query, category });
|
|
1038
|
+
return result?.items ?? [];
|
|
1011
1039
|
}
|
|
1012
1040
|
/**
|
|
1013
|
-
*
|
|
1014
|
-
* `code` (the artifact / fallback default), `overlay` (the saved
|
|
1015
|
-
* customisation, if any), and `effective` (what the runtime sees).
|
|
1041
|
+
* Install a marketplace plugin.
|
|
1016
1042
|
*/
|
|
1017
|
-
async
|
|
1018
|
-
const
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
+
// src/contracts.ts
|
|
1051
|
+
function validatePluginContract(contract) {
|
|
1052
|
+
const errors = [];
|
|
1053
|
+
const warnings = [];
|
|
1054
|
+
if (!contract.name || contract.name.trim().length === 0) {
|
|
1055
|
+
errors.push({ field: "name", message: "Plugin name is required", code: "MISSING_NAME" });
|
|
1056
|
+
}
|
|
1057
|
+
if (!contract.version || !/^\d+\.\d+\.\d+/.test(contract.version)) {
|
|
1058
|
+
errors.push({ field: "version", message: "Valid semver version is required", code: "INVALID_VERSION" });
|
|
1059
|
+
}
|
|
1060
|
+
if (!contract.exports || contract.exports.length === 0) {
|
|
1061
|
+
errors.push({ field: "exports", message: "At least one export is required", code: "NO_EXPORTS" });
|
|
1062
|
+
}
|
|
1063
|
+
if (contract.exports) {
|
|
1064
|
+
const validTypes = ["component", "hook", "utility", "provider"];
|
|
1065
|
+
for (const exp of contract.exports) {
|
|
1066
|
+
if (!exp.name) {
|
|
1067
|
+
errors.push({ field: "exports.name", message: "Export name is required", code: "MISSING_EXPORT_NAME" });
|
|
1068
|
+
}
|
|
1069
|
+
if (!validTypes.includes(exp.type)) {
|
|
1070
|
+
errors.push({ field: "exports.type", message: `Invalid export type: ${exp.type}`, code: "INVALID_EXPORT_TYPE" });
|
|
1071
|
+
}
|
|
1023
1072
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1073
|
+
}
|
|
1074
|
+
if (!contract.permissions || contract.permissions.length === 0) {
|
|
1075
|
+
warnings.push("No permissions declared \u2014 plugin will have minimal access");
|
|
1076
|
+
}
|
|
1077
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
1078
|
+
}
|
|
1079
|
+
function generateContractManifest(contract) {
|
|
1080
|
+
return {
|
|
1081
|
+
$schema: "https://objectui.org/schemas/plugin-contract-v1.json",
|
|
1082
|
+
name: contract.name,
|
|
1083
|
+
version: contract.version,
|
|
1084
|
+
peerDependencies: contract.peerDependencies ?? {},
|
|
1085
|
+
exports: contract.exports.map((exp) => ({
|
|
1086
|
+
name: exp.name,
|
|
1087
|
+
type: exp.type,
|
|
1088
|
+
description: exp.description ?? ""
|
|
1089
|
+
})),
|
|
1090
|
+
permissions: contract.permissions ?? [],
|
|
1091
|
+
api: contract.api ?? {},
|
|
1092
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// src/userState.ts
|
|
1097
|
+
var DEFAULT_RESOURCE = "sys_user_preference";
|
|
1098
|
+
function createObjectStackUserStateAdapter(options) {
|
|
1099
|
+
const {
|
|
1100
|
+
dataSource,
|
|
1101
|
+
userId,
|
|
1102
|
+
key,
|
|
1103
|
+
resource = DEFAULT_RESOURCE,
|
|
1104
|
+
onError = () => {
|
|
1034
1105
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
|
|
1046
|
-
...body.lockDocsUrl !== void 0 ? { lockDocsUrl: body.lockDocsUrl } : {},
|
|
1047
|
-
...body.provenance !== void 0 ? { provenance: body.provenance } : {},
|
|
1048
|
-
...body.packageId !== void 0 ? { packageId: body.packageId } : {},
|
|
1049
|
-
...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
|
|
1050
|
-
...body.editable !== void 0 ? { editable: body.editable } : {},
|
|
1051
|
-
...body.deletable !== void 0 ? { deletable: body.deletable } : {},
|
|
1052
|
-
...body.resettable !== void 0 ? { resettable: body.resettable } : {}
|
|
1106
|
+
} = options;
|
|
1107
|
+
let cachedRowId = null;
|
|
1108
|
+
let saveChain = Promise.resolve();
|
|
1109
|
+
const findExisting = async () => {
|
|
1110
|
+
const params = {
|
|
1111
|
+
$filter: {
|
|
1112
|
+
user_id: userId,
|
|
1113
|
+
key
|
|
1114
|
+
},
|
|
1115
|
+
$top: 1
|
|
1053
1116
|
};
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
if (
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
|
|
1117
|
+
const result = await dataSource.find(resource, params);
|
|
1118
|
+
const rows = result?.data ?? [];
|
|
1119
|
+
const match = rows.find((r) => {
|
|
1120
|
+
if (!r) return false;
|
|
1121
|
+
const userMismatch = r.user_id !== void 0 && r.user_id !== userId;
|
|
1122
|
+
const keyMismatch = r.key !== void 0 && r.key !== key;
|
|
1123
|
+
return !userMismatch && !keyMismatch;
|
|
1124
|
+
});
|
|
1125
|
+
return match ?? null;
|
|
1126
|
+
};
|
|
1127
|
+
const decodeValue = (value) => {
|
|
1128
|
+
if (Array.isArray(value)) return value;
|
|
1129
|
+
if (typeof value === "string") {
|
|
1130
|
+
try {
|
|
1131
|
+
const parsed = JSON.parse(value);
|
|
1132
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1133
|
+
} catch {
|
|
1134
|
+
return [];
|
|
1135
|
+
}
|
|
1074
1136
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1137
|
+
return [];
|
|
1138
|
+
};
|
|
1139
|
+
const upsert = async (items) => {
|
|
1140
|
+
if (cachedRowId !== null) {
|
|
1141
|
+
try {
|
|
1142
|
+
await dataSource.update(resource, cachedRowId, { value: items });
|
|
1143
|
+
return;
|
|
1144
|
+
} catch (updateError) {
|
|
1145
|
+
cachedRowId = null;
|
|
1146
|
+
onError("save", updateError);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
const existing = await findExisting();
|
|
1150
|
+
if (existing && existing.id !== void 0 && existing.id !== null) {
|
|
1151
|
+
cachedRowId = existing.id;
|
|
1152
|
+
await dataSource.update(resource, existing.id, { value: items });
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
const created = await dataSource.create(resource, {
|
|
1157
|
+
user_id: userId,
|
|
1158
|
+
key,
|
|
1159
|
+
value: items
|
|
1160
|
+
});
|
|
1161
|
+
const newId = created?.id;
|
|
1162
|
+
if (newId !== void 0 && newId !== null) cachedRowId = newId;
|
|
1163
|
+
} catch (createError) {
|
|
1164
|
+
const recovered = await findExisting();
|
|
1165
|
+
if (recovered && recovered.id !== void 0 && recovered.id !== null) {
|
|
1166
|
+
cachedRowId = recovered.id;
|
|
1167
|
+
await dataSource.update(resource, recovered.id, { value: items });
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
throw createError;
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
return {
|
|
1174
|
+
async load() {
|
|
1175
|
+
try {
|
|
1176
|
+
const row = await findExisting();
|
|
1177
|
+
if (!row) {
|
|
1178
|
+
cachedRowId = null;
|
|
1179
|
+
return [];
|
|
1180
|
+
}
|
|
1181
|
+
if (row.id !== void 0 && row.id !== null) cachedRowId = row.id;
|
|
1182
|
+
return decodeValue(row.value);
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
onError("load", error);
|
|
1185
|
+
return [];
|
|
1186
|
+
}
|
|
1187
|
+
},
|
|
1188
|
+
async save(items) {
|
|
1189
|
+
const run = saveChain.then(() => upsert(items)).catch((error) => {
|
|
1190
|
+
onError("save", error);
|
|
1191
|
+
});
|
|
1192
|
+
saveChain = run;
|
|
1193
|
+
return run;
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// src/integration.ts
|
|
1199
|
+
var IntegrationManager = class {
|
|
1200
|
+
constructor() {
|
|
1201
|
+
__publicField(this, "integrations", /* @__PURE__ */ new Map());
|
|
1084
1202
|
}
|
|
1085
1203
|
/**
|
|
1086
|
-
*
|
|
1087
|
-
* for pre-delete impact analysis: "Are any views pointing at this
|
|
1088
|
-
* object before I drop it?".
|
|
1204
|
+
* Register a new integration.
|
|
1089
1205
|
*/
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
1093
|
-
if (res.status === 404) return [];
|
|
1094
|
-
if (!res.ok) throw await parseError(res);
|
|
1095
|
-
const data = await res.json();
|
|
1096
|
-
return Array.isArray(data) ? data : data?.items ?? [];
|
|
1206
|
+
register(id, config) {
|
|
1207
|
+
this.integrations.set(id, config);
|
|
1097
1208
|
}
|
|
1098
1209
|
/**
|
|
1099
|
-
*
|
|
1100
|
-
* Idempotent: returns the result even when no overlay row existed.
|
|
1101
|
-
* Pass `state: 'draft'` to discard the pending draft only (keeps the
|
|
1102
|
-
* published overlay intact) — useful for a Studio "Discard draft" button.
|
|
1210
|
+
* Remove an integration.
|
|
1103
1211
|
*/
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
1107
|
-
const headers = { ...this.headers };
|
|
1108
|
-
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
1109
|
-
if (options.actor) headers["X-Actor"] = options.actor;
|
|
1110
|
-
const res = await this.fetchImpl(url, { method: "DELETE", headers });
|
|
1111
|
-
if (!res.ok) throw await parseError(res);
|
|
1112
|
-
return await res.json();
|
|
1212
|
+
unregister(id) {
|
|
1213
|
+
this.integrations.delete(id);
|
|
1113
1214
|
}
|
|
1114
1215
|
/**
|
|
1115
|
-
*
|
|
1116
|
-
* (POST `/meta/:type/:name/publish`). Returns
|
|
1117
|
-
* `{ success, version, seq, message }`. Throws a `404 no_draft` if
|
|
1118
|
-
* nothing is pending and `409 metadata_conflict` if the published
|
|
1119
|
-
* overlay moved while the draft was sitting.
|
|
1216
|
+
* Get all registered integrations.
|
|
1120
1217
|
*/
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
const headers = {
|
|
1124
|
-
...this.headers,
|
|
1125
|
-
"Content-Type": "application/json"
|
|
1126
|
-
};
|
|
1127
|
-
if (options.actor) headers["X-Actor"] = options.actor;
|
|
1128
|
-
const res = await this.fetchImpl(url, {
|
|
1129
|
-
method: "POST",
|
|
1130
|
-
headers,
|
|
1131
|
-
body: JSON.stringify(options.message ? { message: options.message } : {})
|
|
1132
|
-
});
|
|
1133
|
-
if (!res.ok) throw await parseError(res);
|
|
1134
|
-
return await res.json();
|
|
1218
|
+
getAll() {
|
|
1219
|
+
return new Map(this.integrations);
|
|
1135
1220
|
}
|
|
1136
1221
|
/**
|
|
1137
|
-
*
|
|
1138
|
-
* The server reads the history row at `toVersion`, writes its body back
|
|
1139
|
-
* as the active overlay with `operation_type='revert'`. Returns
|
|
1140
|
-
* `{ success, version, seq, restoredFromVersion, message }`. Throws
|
|
1141
|
-
* `404 version_not_found` for unknown versions and `409 version_not_restorable`
|
|
1142
|
-
* when the target version is a delete tombstone.
|
|
1222
|
+
* Get integrations that match a specific event.
|
|
1143
1223
|
*/
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
"Content-Type": "application/json"
|
|
1149
|
-
};
|
|
1150
|
-
if (options.actor) headers["X-Actor"] = options.actor;
|
|
1151
|
-
const res = await this.fetchImpl(url, {
|
|
1152
|
-
method: "POST",
|
|
1153
|
-
headers,
|
|
1154
|
-
body: JSON.stringify({
|
|
1155
|
-
toVersion,
|
|
1156
|
-
...options.message ? { message: options.message } : {}
|
|
1157
|
-
})
|
|
1158
|
-
});
|
|
1159
|
-
if (!res.ok) throw await parseError(res);
|
|
1160
|
-
return await res.json();
|
|
1224
|
+
getForEvent(event) {
|
|
1225
|
+
return Array.from(this.integrations.values()).filter(
|
|
1226
|
+
(integration) => integration.enabled && integration.triggers?.some((t) => t.event === event)
|
|
1227
|
+
);
|
|
1161
1228
|
}
|
|
1162
1229
|
/**
|
|
1163
|
-
*
|
|
1164
|
-
*
|
|
1165
|
-
* `{ added, removed, changed }` where each entry carries a `path` and
|
|
1166
|
-
* the relevant value(s). Useful for "what changed in this draft?" and
|
|
1167
|
-
* pre-rollback previews.
|
|
1230
|
+
* Dispatch an event to all matching integrations.
|
|
1231
|
+
* Returns results for each integration.
|
|
1168
1232
|
*/
|
|
1169
|
-
async
|
|
1170
|
-
const
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
return
|
|
1182
|
-
}
|
|
1183
|
-
/** Fetch the durable history (change log) for one metadata item. */
|
|
1184
|
-
async history(type, name, options = {}) {
|
|
1185
|
-
const params = new URLSearchParams();
|
|
1186
|
-
if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
|
|
1187
|
-
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
1188
|
-
const qs = params.toString();
|
|
1189
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
|
|
1190
|
-
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
1191
|
-
if (!res.ok) throw await parseError(res);
|
|
1192
|
-
return await res.json();
|
|
1233
|
+
async dispatch(event, payload) {
|
|
1234
|
+
const matching = this.getForEvent(event);
|
|
1235
|
+
const results = [];
|
|
1236
|
+
for (const [id, integration] of this.integrations) {
|
|
1237
|
+
if (!matching.includes(integration)) continue;
|
|
1238
|
+
try {
|
|
1239
|
+
await this.send(integration, payload);
|
|
1240
|
+
results.push({ id, success: true });
|
|
1241
|
+
} catch (err) {
|
|
1242
|
+
results.push({ id, success: false, error: err.message });
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return results;
|
|
1193
1246
|
}
|
|
1194
1247
|
/**
|
|
1195
|
-
*
|
|
1196
|
-
* (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
|
|
1197
|
-
* delete/reset attempt — allowed, denied, or forced — so the
|
|
1198
|
-
* Studio "审计日志 / Audit log" tab can render who tried what
|
|
1199
|
-
* and whether a lock blocked it.
|
|
1248
|
+
* Send payload to a specific integration.
|
|
1200
1249
|
*/
|
|
1201
|
-
async
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1250
|
+
async send(integration, payload) {
|
|
1251
|
+
switch (integration.provider) {
|
|
1252
|
+
case "webhook": {
|
|
1253
|
+
const cfg = integration.config;
|
|
1254
|
+
const url = cfg.url;
|
|
1255
|
+
try {
|
|
1256
|
+
const parsed = new URL(url);
|
|
1257
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1258
|
+
throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
|
|
1259
|
+
}
|
|
1260
|
+
} catch (e) {
|
|
1261
|
+
if (e instanceof TypeError) {
|
|
1262
|
+
throw new Error(`Invalid webhook URL: ${url}`);
|
|
1263
|
+
}
|
|
1264
|
+
throw e;
|
|
1265
|
+
}
|
|
1266
|
+
await fetch(url, {
|
|
1267
|
+
method: cfg.method,
|
|
1268
|
+
headers: {
|
|
1269
|
+
"Content-Type": "application/json",
|
|
1270
|
+
...cfg.headers
|
|
1271
|
+
},
|
|
1272
|
+
body: JSON.stringify(payload)
|
|
1273
|
+
});
|
|
1274
|
+
break;
|
|
1275
|
+
}
|
|
1276
|
+
case "slack": {
|
|
1277
|
+
const cfg = integration.config;
|
|
1278
|
+
const url = cfg.webhookUrl;
|
|
1279
|
+
try {
|
|
1280
|
+
const parsed = new URL(url);
|
|
1281
|
+
if (parsed.protocol !== "https:") {
|
|
1282
|
+
throw new Error(`Invalid Slack webhook URL protocol: ${parsed.protocol}`);
|
|
1283
|
+
}
|
|
1284
|
+
} catch (e) {
|
|
1285
|
+
if (e instanceof TypeError) {
|
|
1286
|
+
throw new Error(`Invalid Slack webhook URL: ${url}`);
|
|
1287
|
+
}
|
|
1288
|
+
throw e;
|
|
1289
|
+
}
|
|
1290
|
+
await fetch(url, {
|
|
1291
|
+
method: "POST",
|
|
1292
|
+
headers: { "Content-Type": "application/json" },
|
|
1293
|
+
body: JSON.stringify({
|
|
1294
|
+
channel: cfg.channel,
|
|
1295
|
+
username: cfg.username,
|
|
1296
|
+
icon_emoji: cfg.iconEmoji,
|
|
1297
|
+
text: JSON.stringify(payload)
|
|
1298
|
+
})
|
|
1299
|
+
});
|
|
1300
|
+
break;
|
|
1301
|
+
}
|
|
1302
|
+
// Email and other providers would require server-side implementation
|
|
1303
|
+
default:
|
|
1304
|
+
break;
|
|
1305
|
+
}
|
|
1209
1306
|
}
|
|
1210
1307
|
};
|
|
1211
1308
|
|
|
@@ -1396,38 +1493,107 @@ var FILTER_OPERATOR_ALIASES = {
|
|
|
1396
1493
|
is_null: "isnull",
|
|
1397
1494
|
isnull: "isnull",
|
|
1398
1495
|
is_not_null: "isnotnull",
|
|
1399
|
-
isnotnull: "isnotnull"
|
|
1496
|
+
isnotnull: "isnotnull",
|
|
1497
|
+
// Date comparisons. `before`/`after` are CANONICAL members of the spec's
|
|
1498
|
+
// `VIEW_FILTER_OPERATORS` (ui/view.zod.ts), so a stored view legitimately
|
|
1499
|
+
// carries them — but they are absent from `VALID_AST_OPERATORS`
|
|
1500
|
+
// (data/filter.zod.ts), which gates `isFilterAST()`. Without these two
|
|
1501
|
+
// entries they reached the wire unchanged, the server's AST gate rejected
|
|
1502
|
+
// the shape, and driver-sql skipped the filter ENTIRELY — an unfiltered
|
|
1503
|
+
// result set with no error anywhere. objectstack#3948.
|
|
1504
|
+
before: "<",
|
|
1505
|
+
after: ">"
|
|
1400
1506
|
};
|
|
1401
1507
|
function normalizeFilterOperator(op) {
|
|
1402
1508
|
if (typeof op !== "string") return null;
|
|
1403
1509
|
const lower = op.toLowerCase();
|
|
1404
1510
|
return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
|
|
1405
1511
|
}
|
|
1512
|
+
var MalformedFilterError = class extends Error {
|
|
1513
|
+
constructor(entry, index) {
|
|
1514
|
+
const shown = JSON.stringify(entry) ?? String(entry);
|
|
1515
|
+
super(
|
|
1516
|
+
`Filter entry ${index} is not a usable filter rule (${shown}). Expected { field, operator, value } with a non-empty field.`
|
|
1517
|
+
);
|
|
1518
|
+
__publicField(this, "code", "INVALID_FILTER");
|
|
1519
|
+
__publicField(this, "httpStatus", 400);
|
|
1520
|
+
__publicField(this, "entry");
|
|
1521
|
+
__publicField(this, "index");
|
|
1522
|
+
this.name = "MalformedFilterError";
|
|
1523
|
+
this.entry = entry;
|
|
1524
|
+
this.index = index;
|
|
1525
|
+
}
|
|
1526
|
+
};
|
|
1527
|
+
function isMalformedFilterError(error) {
|
|
1528
|
+
if (!error || typeof error !== "object") return false;
|
|
1529
|
+
const e = error;
|
|
1530
|
+
return e.code === "INVALID_FILTER" || e.name === "MalformedFilterError";
|
|
1531
|
+
}
|
|
1406
1532
|
function objectFilterEntryToAST(entry) {
|
|
1407
1533
|
if (!entry || typeof entry !== "object") return null;
|
|
1408
|
-
const field = entry.field
|
|
1534
|
+
const field = entry.field;
|
|
1409
1535
|
const rawOp = entry.operator ?? entry.op ?? "=";
|
|
1410
1536
|
const op = normalizeFilterOperator(rawOp);
|
|
1411
1537
|
if (!field || !op) return null;
|
|
1412
1538
|
return [String(field), op, entry.value];
|
|
1413
1539
|
}
|
|
1540
|
+
function isObjectFilterEntryForm(filter) {
|
|
1541
|
+
const first = filter[0];
|
|
1542
|
+
return filter.length > 0 && typeof first === "object" && first !== null && !Array.isArray(first) && first.field !== void 0;
|
|
1543
|
+
}
|
|
1544
|
+
function objectFilterEntriesToAST(entries) {
|
|
1545
|
+
const nodes = entries.map((entry, i) => {
|
|
1546
|
+
if (Array.isArray(entry)) return translateFilterChild(entry);
|
|
1547
|
+
const tuple = objectFilterEntryToAST(entry);
|
|
1548
|
+
if (!tuple) throw new MalformedFilterError(entry, i);
|
|
1549
|
+
return tuple;
|
|
1550
|
+
});
|
|
1551
|
+
return nodes.length === 1 ? nodes[0] : ["and", ...nodes];
|
|
1552
|
+
}
|
|
1553
|
+
var LOGICAL_AST_HEADS = /* @__PURE__ */ new Set(["and", "or"]);
|
|
1554
|
+
function translateFilterArray(filter) {
|
|
1555
|
+
if (isObjectFilterEntryForm(filter)) return objectFilterEntriesToAST(filter);
|
|
1556
|
+
const head = filter[0];
|
|
1557
|
+
if (typeof head === "string" && LOGICAL_AST_HEADS.has(head.toLowerCase())) {
|
|
1558
|
+
return [head, ...filter.slice(1).map(translateFilterChild)];
|
|
1559
|
+
}
|
|
1560
|
+
if (filter.every((child) => Array.isArray(child))) return filter.map(translateFilterChild);
|
|
1561
|
+
return filter;
|
|
1562
|
+
}
|
|
1563
|
+
function translateFilterChild(child) {
|
|
1564
|
+
if (Array.isArray(child)) return child.length > 0 ? translateFilterArray(child) : child;
|
|
1565
|
+
if (child && typeof child === "object" && child.field !== void 0) {
|
|
1566
|
+
const tuple = objectFilterEntryToAST(child);
|
|
1567
|
+
if (tuple) return tuple;
|
|
1568
|
+
}
|
|
1569
|
+
return child;
|
|
1570
|
+
}
|
|
1414
1571
|
function translateFilterToAST(filter) {
|
|
1415
1572
|
if (filter === void 0 || filter === null) return void 0;
|
|
1416
1573
|
if (Array.isArray(filter)) {
|
|
1417
1574
|
if (filter.length === 0) return void 0;
|
|
1418
|
-
|
|
1419
|
-
const isObjectForm = filter.length > 0 && typeof first === "object" && first !== null && !Array.isArray(first) && first.field !== void 0;
|
|
1420
|
-
if (isObjectForm) {
|
|
1421
|
-
const tuples = filter.map((entry) => objectFilterEntryToAST(entry)).filter((t) => t !== null);
|
|
1422
|
-
if (tuples.length === 0) return void 0;
|
|
1423
|
-
if (tuples.length === 1) return tuples[0];
|
|
1424
|
-
return ["and", ...tuples];
|
|
1425
|
-
}
|
|
1426
|
-
return filter;
|
|
1575
|
+
return translateFilterArray(filter);
|
|
1427
1576
|
}
|
|
1428
1577
|
if (typeof filter === "object") {
|
|
1429
1578
|
if (Object.keys(filter).length === 0) return void 0;
|
|
1430
|
-
return filter;
|
|
1579
|
+
return convertFiltersToAST(filter);
|
|
1580
|
+
}
|
|
1581
|
+
return void 0;
|
|
1582
|
+
}
|
|
1583
|
+
function serializeOrderBy(orderby) {
|
|
1584
|
+
if (orderby === void 0 || orderby === null) return void 0;
|
|
1585
|
+
if (typeof orderby === "string") {
|
|
1586
|
+
const trimmed = orderby.trim();
|
|
1587
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
1588
|
+
}
|
|
1589
|
+
const shorthand = (field, order) => String(order).toLowerCase() === "desc" ? `-${field}` : field;
|
|
1590
|
+
if (Array.isArray(orderby)) {
|
|
1591
|
+
const parts = orderby.map((item) => typeof item === "string" ? item.trim() : shorthand(item.field, item.order)).filter((s) => s.length > 0);
|
|
1592
|
+
return parts.length > 0 ? parts.join(",") : void 0;
|
|
1593
|
+
}
|
|
1594
|
+
if (typeof orderby === "object") {
|
|
1595
|
+
const parts = Object.entries(orderby).map(([field, order]) => shorthand(field, order));
|
|
1596
|
+
return parts.length > 0 ? parts.join(",") : void 0;
|
|
1431
1597
|
}
|
|
1432
1598
|
return void 0;
|
|
1433
1599
|
}
|
|
@@ -1446,6 +1612,16 @@ async function getSharedDiscovery(baseUrl, fetcher) {
|
|
|
1446
1612
|
function clearSharedDiscoveryCache() {
|
|
1447
1613
|
discoveryCache.clear();
|
|
1448
1614
|
}
|
|
1615
|
+
function readTransactionalBatchCapability(discovery) {
|
|
1616
|
+
const caps = discovery?.capabilities;
|
|
1617
|
+
if (!caps || typeof caps !== "object") return void 0;
|
|
1618
|
+
const value = caps.transactionalBatch;
|
|
1619
|
+
if (typeof value === "boolean") return value;
|
|
1620
|
+
if (value && typeof value === "object" && typeof value.enabled === "boolean") {
|
|
1621
|
+
return value.enabled;
|
|
1622
|
+
}
|
|
1623
|
+
return void 0;
|
|
1624
|
+
}
|
|
1449
1625
|
function is404Error(error) {
|
|
1450
1626
|
if (!error || typeof error !== "object") return false;
|
|
1451
1627
|
const err = error;
|
|
@@ -1453,7 +1629,46 @@ function is404Error(error) {
|
|
|
1453
1629
|
return true;
|
|
1454
1630
|
}
|
|
1455
1631
|
const code = typeof err.code === "string" ? err.code : "";
|
|
1456
|
-
return code
|
|
1632
|
+
return errorCodeIsAnyOf({ code }, ["OBJECT_NOT_FOUND", "RECORD_NOT_FOUND"]);
|
|
1633
|
+
}
|
|
1634
|
+
var AnalyticsNotInstalledError = class extends Error {
|
|
1635
|
+
constructor(surface, detail) {
|
|
1636
|
+
super(
|
|
1637
|
+
`Analytics capability is not installed on this deployment \u2014 ${surface} is unavailable. Install @objectstack/service-analytics and mount AnalyticsServicePlugin to enable it.` + (detail ? ` (server said: ${detail})` : "")
|
|
1638
|
+
);
|
|
1639
|
+
__publicField(this, "code", "ANALYTICS_NOT_INSTALLED");
|
|
1640
|
+
/** The surface that was unavailable, for the message a host renders. */
|
|
1641
|
+
__publicField(this, "surface");
|
|
1642
|
+
this.name = "AnalyticsNotInstalledError";
|
|
1643
|
+
this.surface = surface;
|
|
1644
|
+
}
|
|
1645
|
+
};
|
|
1646
|
+
function isAnalyticsNotInstalledError(error) {
|
|
1647
|
+
if (!error || typeof error !== "object") return false;
|
|
1648
|
+
return error.code === "ANALYTICS_NOT_INSTALLED";
|
|
1649
|
+
}
|
|
1650
|
+
var AnalyticsQueryRejectedError = class extends Error {
|
|
1651
|
+
constructor(detail, serverCode) {
|
|
1652
|
+
super(
|
|
1653
|
+
`Analytics rejected the query: ${detail ?? "the request body does not match the AnalyticsQuery contract"}`
|
|
1654
|
+
);
|
|
1655
|
+
__publicField(this, "code", "ANALYTICS_QUERY_REJECTED");
|
|
1656
|
+
/** The server's own error code (e.g. `VALIDATION_FAILED`), when it sent one. */
|
|
1657
|
+
__publicField(this, "serverCode");
|
|
1658
|
+
this.name = "AnalyticsQueryRejectedError";
|
|
1659
|
+
this.serverCode = serverCode;
|
|
1660
|
+
}
|
|
1661
|
+
};
|
|
1662
|
+
function classifyAnalyticsFailure(error) {
|
|
1663
|
+
const err = error ?? {};
|
|
1664
|
+
const code = typeof err.code === "string" ? err.code : void 0;
|
|
1665
|
+
const message = typeof err.message === "string" ? err.message : void 0;
|
|
1666
|
+
const status = typeof err.httpStatus === "number" ? err.httpStatus : typeof err.status === "number" ? err.status : typeof err.statusCode === "number" ? err.statusCode : void 0;
|
|
1667
|
+
if (code === "VALIDATION_FAILED" || status === 400) return { kind: "rejected", code, message };
|
|
1668
|
+
if (status === 404 || status === 501 || code === "NOT_IMPLEMENTED" || code === "ROUTE_NOT_FOUND") {
|
|
1669
|
+
return { kind: "not-installed", code, message };
|
|
1670
|
+
}
|
|
1671
|
+
return { kind: "unknown", code, message };
|
|
1457
1672
|
}
|
|
1458
1673
|
var ConcurrentUpdateError = class extends Error {
|
|
1459
1674
|
constructor(opts) {
|
|
@@ -1475,9 +1690,25 @@ function isConcurrentUpdateError(error) {
|
|
|
1475
1690
|
function normaliseClientError(error) {
|
|
1476
1691
|
if (!error || typeof error !== "object") return error;
|
|
1477
1692
|
const e = error;
|
|
1693
|
+
const details = e.details ?? {};
|
|
1694
|
+
if (e.code === "VALIDATION_FAILED" || e.name === "ValidationError") {
|
|
1695
|
+
const rawFields = Array.isArray(details.fields) ? details.fields : Array.isArray(e.fields) ? e.fields : [];
|
|
1696
|
+
const validationErrors = rawFields.map((f) => {
|
|
1697
|
+
const rec = f ?? {};
|
|
1698
|
+
const field = typeof rec.field === "string" ? rec.field : void 0;
|
|
1699
|
+
if (!field) return null;
|
|
1700
|
+
const message = typeof rec.message === "string" && rec.message.trim() ? rec.message : typeof rec.code === "string" ? rec.code : "";
|
|
1701
|
+
return { field, message };
|
|
1702
|
+
}).filter((x) => x !== null);
|
|
1703
|
+
return new ValidationError(
|
|
1704
|
+
typeof e.message === "string" ? e.message : "Validation failed",
|
|
1705
|
+
validationErrors[0]?.field,
|
|
1706
|
+
validationErrors,
|
|
1707
|
+
{ fields: rawFields }
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1478
1710
|
if (e.code !== "CONCURRENT_UPDATE" && e.httpStatus !== 409) return error;
|
|
1479
1711
|
if (e.code !== "CONCURRENT_UPDATE") return error;
|
|
1480
|
-
const details = e.details ?? {};
|
|
1481
1712
|
return new ConcurrentUpdateError({
|
|
1482
1713
|
currentVersion: typeof details.currentVersion === "string" ? details.currentVersion : null,
|
|
1483
1714
|
currentRecord: details.currentRecord ?? null,
|
|
@@ -1491,7 +1722,7 @@ function createQuietHttpLogger() {
|
|
|
1491
1722
|
const errBody = meta.error;
|
|
1492
1723
|
if (errBody && typeof errBody === "object") {
|
|
1493
1724
|
const code = errBody.code;
|
|
1494
|
-
if (code
|
|
1725
|
+
if (errorCodeIsAnyOf({ code }, ["OBJECT_NOT_FOUND", "RECORD_NOT_FOUND"])) return true;
|
|
1495
1726
|
}
|
|
1496
1727
|
return false;
|
|
1497
1728
|
};
|
|
@@ -1535,6 +1766,8 @@ var ObjectStackAdapter = class {
|
|
|
1535
1766
|
__publicField(this, "reconnectAttempts", 0);
|
|
1536
1767
|
__publicField(this, "baseUrl");
|
|
1537
1768
|
__publicField(this, "token");
|
|
1769
|
+
/** One "analytics capability is missing" console line per adapter, not per widget. */
|
|
1770
|
+
__publicField(this, "analyticsCapabilityWarned", false);
|
|
1538
1771
|
__publicField(this, "fetchImpl");
|
|
1539
1772
|
// In-flight find() requests keyed by resource + serialized params.
|
|
1540
1773
|
// Coalesces concurrent identical reads (e.g. React StrictMode double-mount,
|
|
@@ -1546,11 +1779,30 @@ var ObjectStackAdapter = class {
|
|
|
1546
1779
|
// so optional collections like sys_presence don't hammer the server with
|
|
1547
1780
|
// failing requests on every record open / panel render.
|
|
1548
1781
|
__publicField(this, "missingResources", /* @__PURE__ */ new Set());
|
|
1782
|
+
// Set once the server has told us it can't do a cross-object transactional
|
|
1783
|
+
// batch (the client SDK's data.batchTransaction threw HTTP 404/405/501). After
|
|
1784
|
+
// that, batchTransaction skips the SDK call and serves every call via the
|
|
1785
|
+
// client-side emulation — the non-atomic fallback lives HERE, isolated to the
|
|
1786
|
+
// one adapter that has to cope with a backend lacking server atomicity (#2679).
|
|
1787
|
+
__publicField(this, "batchUnsupported", false);
|
|
1788
|
+
// The server's declared cross-object atomic-batch capability, read from
|
|
1789
|
+
// discovery at connect() (framework #3298 / objectui #2693). `true` → the
|
|
1790
|
+
// backend GUARANTEES an atomic `/batch`, so batchTransaction trusts it and
|
|
1791
|
+
// never degrades to the non-atomic emulation (any failure surfaces as a real
|
|
1792
|
+
// error). `false` or `undefined` (capability absent → backend predates #3298)
|
|
1793
|
+
// → keep the legacy runtime-probe + emulation fallback so a save is still
|
|
1794
|
+
// possible; dropping it there would turn "saves, less safe" into "no save
|
|
1795
|
+
// path" on older backends (#2679 compatibility constraint).
|
|
1796
|
+
__publicField(this, "atomicBatchCapability");
|
|
1549
1797
|
// Subscribers registered via onMutation(). Emitted after each successful
|
|
1550
1798
|
// create/update/delete so data-bound views (ListView, ObjectView, kanban,
|
|
1551
1799
|
// calendar) auto-refresh — the interface ListView relies on to reflect
|
|
1552
1800
|
// inline-edit "Save All" writes without a manual reload.
|
|
1553
1801
|
__publicField(this, "mutationListeners", /* @__PURE__ */ new Set());
|
|
1802
|
+
// Subscribers registered via onWriteWarning(). Emitted after a create/update
|
|
1803
|
+
// whose response carried `droppedFields` (framework #3431/#3455) so the app
|
|
1804
|
+
// shell can surface a toast instead of the strip passing silently.
|
|
1805
|
+
__publicField(this, "writeWarningListeners", /* @__PURE__ */ new Set());
|
|
1554
1806
|
this.client = new import_client.ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
|
|
1555
1807
|
this.metadataCache = new MetadataCache(config.cache);
|
|
1556
1808
|
this.autoReconnect = config.autoReconnect ?? true;
|
|
@@ -1584,6 +1836,7 @@ var ObjectStackAdapter = class {
|
|
|
1584
1836
|
return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
|
|
1585
1837
|
});
|
|
1586
1838
|
this.client.discoveryInfo = data;
|
|
1839
|
+
this.atomicBatchCapability = readTransactionalBatchCapability(data);
|
|
1587
1840
|
this.connected = true;
|
|
1588
1841
|
this.reconnectAttempts = 0;
|
|
1589
1842
|
this.setConnectionState("connected");
|
|
@@ -1721,6 +1974,77 @@ var ObjectStackAdapter = class {
|
|
|
1721
1974
|
promise.then(cleanup, cleanup);
|
|
1722
1975
|
return promise;
|
|
1723
1976
|
}
|
|
1977
|
+
/**
|
|
1978
|
+
* Full-text search across every searchable object in a single round-trip.
|
|
1979
|
+
*
|
|
1980
|
+
* Hits `GET /api/v1/search?q=`, the platform's global search endpoint served
|
|
1981
|
+
* by the registered search service (the pinyin full-text plugin) and backed
|
|
1982
|
+
* by `metadata-protocol`'s `searchAll`. Unlike `find(resource, { $search })`
|
|
1983
|
+
* — a per-object metadata-driven search (ADR-0061) — this consults the search
|
|
1984
|
+
* index and ranks hits across objects, so it surfaces records the per-object
|
|
1985
|
+
* fanout misses. Global affordances (⌘K command palette, search page) prefer
|
|
1986
|
+
* this path (framework #3371).
|
|
1987
|
+
*
|
|
1988
|
+
* Returns `{ query, hits }`. A backend without the search plugin installed
|
|
1989
|
+
* answers `404`; we treat that as "no global search here" and return an empty
|
|
1990
|
+
* hit set so callers can fall back to a per-object fanout rather than surface
|
|
1991
|
+
* an error.
|
|
1992
|
+
*/
|
|
1993
|
+
async searchAll(query, options) {
|
|
1994
|
+
const trimmed = (query ?? "").trim();
|
|
1995
|
+
if (trimmed === "") return { query: "", hits: [] };
|
|
1996
|
+
await this.connect();
|
|
1997
|
+
const queryParams = new URLSearchParams();
|
|
1998
|
+
queryParams.set("q", trimmed);
|
|
1999
|
+
if (options?.limit != null && options.limit > 0) {
|
|
2000
|
+
queryParams.set("limit", String(options.limit));
|
|
2001
|
+
}
|
|
2002
|
+
if (options?.objects && options.objects.length > 0) {
|
|
2003
|
+
queryParams.set("objects", options.objects.join(","));
|
|
2004
|
+
}
|
|
2005
|
+
const baseUrl = (this.baseUrl || "").replace(/\/$/, "");
|
|
2006
|
+
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
2007
|
+
const searchPath = hasApiVersionSuffix ? "/search" : "/api/v1/search";
|
|
2008
|
+
const url = `${baseUrl}${searchPath}?${queryParams.toString()}`;
|
|
2009
|
+
const headers = {
|
|
2010
|
+
"Content-Type": "application/json"
|
|
2011
|
+
};
|
|
2012
|
+
if (this.token) {
|
|
2013
|
+
headers["Authorization"] = `Bearer ${this.token}`;
|
|
2014
|
+
}
|
|
2015
|
+
const res = await this.fetchImpl(url, { method: "GET", headers });
|
|
2016
|
+
if (!res.ok) {
|
|
2017
|
+
if (res.status === 404) return { query: trimmed, hits: [] };
|
|
2018
|
+
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
|
|
2019
|
+
const err = new Error(
|
|
2020
|
+
errorBody?.error?.message || errorBody?.message || res.statusText
|
|
2021
|
+
);
|
|
2022
|
+
err.status = res.status;
|
|
2023
|
+
throw err;
|
|
2024
|
+
}
|
|
2025
|
+
const body = await res.json();
|
|
2026
|
+
const payload = body && typeof body === "object" && typeof body.success === "boolean" && "data" in body ? body.data : body;
|
|
2027
|
+
const rawHits = Array.isArray(payload?.hits) ? payload.hits : Array.isArray(payload) ? payload : [];
|
|
2028
|
+
const hits = [];
|
|
2029
|
+
for (const entry of rawHits) {
|
|
2030
|
+
if (!entry || typeof entry !== "object") continue;
|
|
2031
|
+
const h = entry;
|
|
2032
|
+
const object = h.object ?? h.objectName ?? h.object_name;
|
|
2033
|
+
const id = h.id ?? h.record?.id ?? h.record?._id;
|
|
2034
|
+
if (typeof object !== "string" || id == null) continue;
|
|
2035
|
+
hits.push({
|
|
2036
|
+
object,
|
|
2037
|
+
id: String(id),
|
|
2038
|
+
title: typeof h.title === "string" ? h.title : void 0,
|
|
2039
|
+
snippet: typeof h.snippet === "string" ? h.snippet : void 0,
|
|
2040
|
+
record: h.record && typeof h.record === "object" ? h.record : void 0
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
return {
|
|
2044
|
+
query: typeof payload?.query === "string" ? payload.query : trimmed,
|
|
2045
|
+
hits
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
1724
2048
|
/**
|
|
1725
2049
|
* Find a single record by ID.
|
|
1726
2050
|
*/
|
|
@@ -1784,11 +2108,84 @@ var ObjectStackAdapter = class {
|
|
|
1784
2108
|
this.mutationListeners.delete(callback);
|
|
1785
2109
|
};
|
|
1786
2110
|
}
|
|
2111
|
+
/**
|
|
2112
|
+
* Notify all write-warning subscribers. Isolated like {@link emitMutation}: a
|
|
2113
|
+
* throwing listener must not break the write or starve the others.
|
|
2114
|
+
*/
|
|
2115
|
+
emitWriteWarning(event) {
|
|
2116
|
+
for (const listener of this.writeWarningListeners) {
|
|
2117
|
+
try {
|
|
2118
|
+
listener(event);
|
|
2119
|
+
} catch (err) {
|
|
2120
|
+
console.warn("ObjectStackAdapter: write-warning listener error", err);
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
/**
|
|
2125
|
+
* Read `droppedFields` off a create/update response (framework #3431/#3455)
|
|
2126
|
+
* and, when present, notify write-warning subscribers. Tolerant of a client
|
|
2127
|
+
* whose response type predates `droppedFields`: the field is read structurally
|
|
2128
|
+
* and validated, so an older client (or a backend that never drops) is a no-op.
|
|
2129
|
+
*/
|
|
2130
|
+
notifyDroppedFields(operation, resource, result, id) {
|
|
2131
|
+
const dropped = result?.droppedFields;
|
|
2132
|
+
if (!Array.isArray(dropped) || dropped.length === 0) return;
|
|
2133
|
+
const droppedFields = dropped.filter(
|
|
2134
|
+
(e) => !!e && typeof e === "object" && Array.isArray(e.fields) && e.fields.length > 0
|
|
2135
|
+
);
|
|
2136
|
+
if (droppedFields.length === 0) return;
|
|
2137
|
+
this.emitWriteWarning({ operation, resource, ...id !== void 0 ? { id } : {}, droppedFields });
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Same, for the cross-object transactional batch (framework #3794). Its
|
|
2141
|
+
* response hangs the events off a top-level `droppedFields` list, each tagged
|
|
2142
|
+
* with the `index` of the operation it came from — `results` entries are bare
|
|
2143
|
+
* record echoes with nowhere to hang a per-row list.
|
|
2144
|
+
*
|
|
2145
|
+
* This is the path that matters most for the warning: `batchTransaction` is
|
|
2146
|
+
* how the console's record form saves a master-detail record, so a
|
|
2147
|
+
* `readonlyWhen`-locked field edited in that form was stripped server-side
|
|
2148
|
+
* while the UI reported a plain success. The operation kind is taken from the
|
|
2149
|
+
* originating op so the toast doesn't call an update a create.
|
|
2150
|
+
*/
|
|
2151
|
+
notifyBatchDroppedFields(operations, payload) {
|
|
2152
|
+
const dropped = payload?.droppedFields;
|
|
2153
|
+
if (!Array.isArray(dropped) || dropped.length === 0) return;
|
|
2154
|
+
for (const entry of dropped) {
|
|
2155
|
+
if (!entry || typeof entry !== "object") continue;
|
|
2156
|
+
const e = entry;
|
|
2157
|
+
if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
|
|
2158
|
+
const op = typeof e.index === "number" ? operations[e.index] : void 0;
|
|
2159
|
+
const operation = (op?.action ?? "create") === "create" ? "create" : "update";
|
|
2160
|
+
this.emitWriteWarning({
|
|
2161
|
+
operation,
|
|
2162
|
+
resource: e.object ?? op?.object ?? "",
|
|
2163
|
+
...op?.id !== void 0 && op?.id !== null ? { id: op.id } : {},
|
|
2164
|
+
droppedFields: [{ object: e.object, fields: e.fields, reason: e.reason }]
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* Subscribe to write-warning events (a create/update dropped caller-supplied
|
|
2170
|
+
* fields — #3431/#3455). Returns an unsubscribe function. The app shell uses
|
|
2171
|
+
* this to toast the user; the write itself already succeeded.
|
|
2172
|
+
*/
|
|
2173
|
+
onWriteWarning(callback) {
|
|
2174
|
+
this.writeWarningListeners.add(callback);
|
|
2175
|
+
return () => {
|
|
2176
|
+
this.writeWarningListeners.delete(callback);
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
1787
2179
|
async create(resource, data) {
|
|
1788
2180
|
await this.connect();
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
2181
|
+
try {
|
|
2182
|
+
const result = await this.client.data.create(resource, data);
|
|
2183
|
+
this.emitMutation({ type: "create", resource, record: { ...result.record } });
|
|
2184
|
+
this.notifyDroppedFields("create", resource, result, result.record?.id);
|
|
2185
|
+
return result.record;
|
|
2186
|
+
} catch (err) {
|
|
2187
|
+
throw normaliseClientError(err);
|
|
2188
|
+
}
|
|
1792
2189
|
}
|
|
1793
2190
|
/**
|
|
1794
2191
|
* Update an existing record.
|
|
@@ -1811,6 +2208,7 @@ var ObjectStackAdapter = class {
|
|
|
1811
2208
|
opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
|
|
1812
2209
|
);
|
|
1813
2210
|
this.emitMutation({ type: "update", resource, id, record: { ...result.record } });
|
|
2211
|
+
this.notifyDroppedFields("update", resource, result, id);
|
|
1814
2212
|
return result.record;
|
|
1815
2213
|
} catch (err) {
|
|
1816
2214
|
throw normaliseClientError(err);
|
|
@@ -1933,33 +2331,85 @@ var ObjectStackAdapter = class {
|
|
|
1933
2331
|
* @returns Promise resolving to array of results
|
|
1934
2332
|
*/
|
|
1935
2333
|
/**
|
|
1936
|
-
* Cross-object transactional batch (ObjectStack #1604
|
|
1937
|
-
* in ONE server transaction — commit all or roll back
|
|
1938
|
-
* `{ $ref: <earlier op index> }` resolves to that op's
|
|
1939
|
-
* can reference its parent created earlier in the same
|
|
2334
|
+
* Cross-object transactional batch (ObjectStack #1604 / ADR-0034 item 4).
|
|
2335
|
+
* Runs the operations in ONE server transaction — commit all or roll back
|
|
2336
|
+
* all. A field value of `{ $ref: <earlier op index> }` resolves to that op's
|
|
2337
|
+
* created id, so a child can reference its parent created earlier in the same
|
|
2338
|
+
* batch (master-detail).
|
|
2339
|
+
*
|
|
2340
|
+
* Transport: the published `@objectstack/client` SDK method
|
|
2341
|
+
* `data.batchTransaction` (framework #3271; shipped since client v16, our
|
|
2342
|
+
* dependency floor). Per AGENTS.md §7 data always flows through the client —
|
|
2343
|
+
* never a hand-rolled `fetch('/api/v1/batch')`.
|
|
2344
|
+
*
|
|
2345
|
+
* Fallback decision — declarative capability negotiation (framework #3298 /
|
|
2346
|
+
* objectui #2693). At connect() we read `capabilities.transactionalBatch`
|
|
2347
|
+
* from discovery:
|
|
2348
|
+
* - Declared `true` → the backend GUARANTEES atomicity (declared ===
|
|
2349
|
+
* enforced). We TRUST it: any batch failure — including 404/405/501 —
|
|
2350
|
+
* surfaces as a real error. No non-atomic client-side compensation. This
|
|
2351
|
+
* is the path modern backends take.
|
|
2352
|
+
* - Declared `false`, or ABSENT (backend predates #3298) → we can't rely on
|
|
2353
|
+
* server atomicity, so we keep the legacy behaviour: on 404/405 (no
|
|
2354
|
+
* endpoint) or 501 (runtime without transactions) degrade to the
|
|
2355
|
+
* client-side, NON-atomic {@link emulateBatchTransaction} so a save is
|
|
2356
|
+
* still possible. Removing that here would regress older backends from
|
|
2357
|
+
* "saves, less safe" to "no save path" (#2679 compatibility constraint).
|
|
2358
|
+
* The non-atomic fallback stays isolated to THIS adapter.
|
|
1940
2359
|
*/
|
|
1941
2360
|
async batchTransaction(operations) {
|
|
1942
|
-
|
|
1943
|
-
const
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
})
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
2361
|
+
await this.connect();
|
|
2362
|
+
const guaranteed = this.atomicBatchCapability === true;
|
|
2363
|
+
if (!guaranteed && this.batchUnsupported) {
|
|
2364
|
+
return emulateBatchTransaction(this, operations);
|
|
2365
|
+
}
|
|
2366
|
+
try {
|
|
2367
|
+
const payload = await this.client.data.batchTransaction(operations);
|
|
2368
|
+
this.emitBatchMutations(operations, payload?.results);
|
|
2369
|
+
this.notifyBatchDroppedFields(operations, payload);
|
|
2370
|
+
return payload;
|
|
2371
|
+
} catch (err) {
|
|
2372
|
+
const status = this.errorStatusOf(err);
|
|
2373
|
+
if (!guaranteed && this.batchStatusUnsupported(status)) {
|
|
2374
|
+
return this.fallbackToEmulation(operations, status);
|
|
2375
|
+
}
|
|
2376
|
+
throw err;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
/** True for statuses that mean "this backend can't do a transactional batch". */
|
|
2380
|
+
batchStatusUnsupported(status) {
|
|
2381
|
+
return status === 404 || status === 405 || status === 501;
|
|
2382
|
+
}
|
|
2383
|
+
/** Best-effort HTTP status extraction from a thrown SDK/client error. */
|
|
2384
|
+
errorStatusOf(err) {
|
|
2385
|
+
if (!err || typeof err !== "object") return void 0;
|
|
2386
|
+
const e = err;
|
|
2387
|
+
const s = e.httpStatus ?? e.status ?? e.statusCode;
|
|
2388
|
+
return typeof s === "number" ? s : void 0;
|
|
2389
|
+
}
|
|
2390
|
+
/** Mark the endpoint unsupported (warn once) and serve via emulation. */
|
|
2391
|
+
fallbackToEmulation(operations, status) {
|
|
2392
|
+
if (!this.batchUnsupported) {
|
|
2393
|
+
this.batchUnsupported = true;
|
|
2394
|
+
console.warn(
|
|
2395
|
+
`ObjectStackAdapter: POST /api/v1/batch unavailable (HTTP ${status ?? "?"}) \u2014 falling back to non-atomic client-side batch emulation. Cross-object saves on this backend are best-effort, not transactional.`
|
|
1959
2396
|
);
|
|
1960
2397
|
}
|
|
1961
|
-
|
|
1962
|
-
|
|
2398
|
+
return emulateBatchTransaction(this, operations);
|
|
2399
|
+
}
|
|
2400
|
+
/**
|
|
2401
|
+
* Emit one DataSourceMutationEvent per committed operation so the invalidation bus
|
|
2402
|
+
* (#2269) sees writes that went through /batch exactly like single
|
|
2403
|
+
* create/update/delete calls — master-detail ModalForm saves otherwise leave
|
|
2404
|
+
* related lists and count badges stale (#2582). `results` is index-aligned
|
|
2405
|
+
* with `operations`; creates take id/record from the server echo.
|
|
2406
|
+
*
|
|
2407
|
+
* Only called on the server-committed paths. The emulation branch drives the
|
|
2408
|
+
* adapter's own create/update/delete primitives, which already emit — so it
|
|
2409
|
+
* must NOT be routed through here, or events would double-fire.
|
|
2410
|
+
*/
|
|
2411
|
+
emitBatchMutations(operations, rawResults) {
|
|
2412
|
+
const results = Array.isArray(rawResults) ? rawResults : [];
|
|
1963
2413
|
operations.forEach((op, i) => {
|
|
1964
2414
|
const action = op.action ?? "create";
|
|
1965
2415
|
const echo = results[i];
|
|
@@ -1971,7 +2421,6 @@ var ObjectStackAdapter = class {
|
|
|
1971
2421
|
this.emitMutation({ type: "delete", resource: op.object, id: op.id });
|
|
1972
2422
|
}
|
|
1973
2423
|
});
|
|
1974
|
-
return payload;
|
|
1975
2424
|
}
|
|
1976
2425
|
async bulk(resource, operation, data) {
|
|
1977
2426
|
await this.connect();
|
|
@@ -2302,20 +2751,8 @@ var ObjectStackAdapter = class {
|
|
|
2302
2751
|
const selectFields = params.$select.includes("id") ? params.$select : ["id", ...params.$select];
|
|
2303
2752
|
queryParams.set("select", selectFields.join(","));
|
|
2304
2753
|
}
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
const sortStr = params.$orderby.map((item) => {
|
|
2308
|
-
if (typeof item === "string") return item;
|
|
2309
|
-
const field = item.field;
|
|
2310
|
-
const order = item.order || "asc";
|
|
2311
|
-
return order === "desc" ? `-${field}` : field;
|
|
2312
|
-
}).join(",");
|
|
2313
|
-
queryParams.set("sort", sortStr);
|
|
2314
|
-
} else {
|
|
2315
|
-
const sortStr = Object.entries(params.$orderby).map(([field, order]) => order === "desc" ? `-${field}` : field).join(",");
|
|
2316
|
-
queryParams.set("sort", sortStr);
|
|
2317
|
-
}
|
|
2318
|
-
}
|
|
2754
|
+
const sortStr = serializeOrderBy(params.$orderby);
|
|
2755
|
+
if (sortStr) queryParams.set("sort", sortStr);
|
|
2319
2756
|
if (params.$filter !== void 0 && params.$filter !== null) {
|
|
2320
2757
|
const translated = translateFilterToAST(params.$filter);
|
|
2321
2758
|
if (translated !== void 0) {
|
|
@@ -2379,6 +2816,13 @@ var ObjectStackAdapter = class {
|
|
|
2379
2816
|
queryParams.set("filter", JSON.stringify(translated));
|
|
2380
2817
|
}
|
|
2381
2818
|
}
|
|
2819
|
+
const searchTerm = typeof request.search === "string" ? request.search.trim() : "";
|
|
2820
|
+
if (searchTerm) {
|
|
2821
|
+
queryParams.set("search", searchTerm);
|
|
2822
|
+
if (request.searchFields && request.searchFields.length > 0) {
|
|
2823
|
+
queryParams.set("searchFields", request.searchFields.join(","));
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2382
2826
|
const baseUrl = this.baseUrl.replace(/\/$/, "");
|
|
2383
2827
|
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
2384
2828
|
const dataPath = hasApiVersionSuffix ? "/data" : "/api/v1/data";
|
|
@@ -2407,38 +2851,14 @@ var ObjectStackAdapter = class {
|
|
|
2407
2851
|
const isEmpty = Array.isArray(params.$filter) ? params.$filter.length === 0 : typeof params.$filter === "object" && Object.keys(params.$filter).length === 0;
|
|
2408
2852
|
if (!isEmpty) {
|
|
2409
2853
|
if (Array.isArray(params.$filter)) {
|
|
2410
|
-
|
|
2411
|
-
if (isObjectForm) {
|
|
2412
|
-
const tuples = params.$filter.map((entry) => objectFilterEntryToAST(entry)).filter((t) => t !== null);
|
|
2413
|
-
if (tuples.length === 0) {
|
|
2414
|
-
} else if (tuples.length === 1) {
|
|
2415
|
-
options.filters = tuples[0];
|
|
2416
|
-
} else {
|
|
2417
|
-
options.filters = ["and", ...tuples];
|
|
2418
|
-
}
|
|
2419
|
-
} else {
|
|
2420
|
-
options.filters = params.$filter;
|
|
2421
|
-
}
|
|
2854
|
+
options.filters = translateFilterArray(params.$filter);
|
|
2422
2855
|
} else {
|
|
2423
2856
|
options.filters = convertFiltersToAST(params.$filter);
|
|
2424
2857
|
}
|
|
2425
2858
|
}
|
|
2426
2859
|
}
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
options.sort = params.$orderby.map((item) => {
|
|
2430
|
-
if (typeof item === "string") return item;
|
|
2431
|
-
const field = item.field;
|
|
2432
|
-
const order = item.order || "asc";
|
|
2433
|
-
return order === "desc" ? `-${field}` : field;
|
|
2434
|
-
});
|
|
2435
|
-
} else {
|
|
2436
|
-
const sortArray = Object.entries(params.$orderby).map(([field, order]) => {
|
|
2437
|
-
return order === "desc" ? `-${field}` : field;
|
|
2438
|
-
});
|
|
2439
|
-
options.sort = sortArray;
|
|
2440
|
-
}
|
|
2441
|
-
}
|
|
2860
|
+
const sort = serializeOrderBy(params.$orderby);
|
|
2861
|
+
if (sort) options.sort = sort;
|
|
2442
2862
|
if (params.$skip !== void 0) {
|
|
2443
2863
|
options.skip = params.$skip;
|
|
2444
2864
|
}
|
|
@@ -2692,22 +3112,18 @@ var ObjectStackAdapter = class {
|
|
|
2692
3112
|
*/
|
|
2693
3113
|
async updateViewConfig(objectName, viewId, config) {
|
|
2694
3114
|
await this.connect();
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
return result ?? void 0;
|
|
2708
|
-
} catch (err) {
|
|
2709
|
-
throw err;
|
|
2710
|
-
}
|
|
3115
|
+
const merged = { ...config || {}, object: config?.object || objectName, name: viewId };
|
|
3116
|
+
const result = await this.client.meta.saveItem(
|
|
3117
|
+
"view",
|
|
3118
|
+
viewId,
|
|
3119
|
+
merged
|
|
3120
|
+
);
|
|
3121
|
+
const cacheKey = `view:${objectName}:${viewId}`;
|
|
3122
|
+
this.metadataCache.invalidate?.(cacheKey);
|
|
3123
|
+
this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
|
|
3124
|
+
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
3125
|
+
if (result && result.item) return result.item;
|
|
3126
|
+
return result ?? void 0;
|
|
2711
3127
|
}
|
|
2712
3128
|
/**
|
|
2713
3129
|
* List user-created views for a given object via the metadata overlay
|
|
@@ -2719,11 +3135,17 @@ var ObjectStackAdapter = class {
|
|
|
2719
3135
|
* Filters by `data.object === objectName` (or top-level `object`)
|
|
2720
3136
|
* client-side because the metadata index is name-only, not field-typed.
|
|
2721
3137
|
*/
|
|
2722
|
-
async listViews(objectName) {
|
|
3138
|
+
async listViews(objectName, options) {
|
|
2723
3139
|
await this.connect();
|
|
2724
3140
|
try {
|
|
2725
|
-
|
|
2726
|
-
|
|
3141
|
+
let items;
|
|
3142
|
+
if (options?.previewDrafts) {
|
|
3143
|
+
const draftItems = await this.metadataClient().withPreviewDrafts(true).list("view");
|
|
3144
|
+
items = Array.isArray(draftItems) ? draftItems : [];
|
|
3145
|
+
} else {
|
|
3146
|
+
const result = await this.client.meta.getItems("view");
|
|
3147
|
+
items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
|
|
3148
|
+
}
|
|
2727
3149
|
const FORM_FAMILY = /* @__PURE__ */ new Set(["form", "detail"]);
|
|
2728
3150
|
return items.filter((v) => {
|
|
2729
3151
|
if (!v) return false;
|
|
@@ -2734,21 +3156,36 @@ var ObjectStackAdapter = class {
|
|
|
2734
3156
|
return !(viewKind && FORM_FAMILY.has(viewKind));
|
|
2735
3157
|
}).map((v) => {
|
|
2736
3158
|
const spec = v.list ?? v;
|
|
3159
|
+
const isDraft = v._draft === true || spec?._draft === true;
|
|
2737
3160
|
if (spec && spec.config && typeof spec.config === "object") {
|
|
2738
3161
|
return {
|
|
2739
3162
|
...spec.config,
|
|
2740
3163
|
name: spec.name ?? spec.config.name,
|
|
2741
3164
|
label: spec.label ?? spec.config.label,
|
|
2742
|
-
isDefault: !!spec.isDefault
|
|
3165
|
+
isDefault: !!spec.isDefault,
|
|
3166
|
+
...isDraft ? { _draft: true } : {}
|
|
2743
3167
|
};
|
|
2744
3168
|
}
|
|
2745
|
-
return spec;
|
|
3169
|
+
return isDraft ? { ...spec, _draft: true } : spec;
|
|
2746
3170
|
});
|
|
2747
3171
|
} catch (err) {
|
|
2748
3172
|
console.warn("[OBJECTSTACKDataSource] listViews failed:", err);
|
|
2749
3173
|
return [];
|
|
2750
3174
|
}
|
|
2751
3175
|
}
|
|
3176
|
+
/**
|
|
3177
|
+
* Build a {@link MetadataClient} bound to this adapter's server + auth. Used
|
|
3178
|
+
* by draft-aware reads (`listViews({ previewDrafts })`) so the `/meta` route,
|
|
3179
|
+
* `?preview=draft` flag, and environment scoping live in the SDK rather than
|
|
3180
|
+
* being hand-assembled at each call site (#2767 P3).
|
|
3181
|
+
*/
|
|
3182
|
+
metadataClient() {
|
|
3183
|
+
return new MetadataClient({
|
|
3184
|
+
baseUrl: this.baseUrl,
|
|
3185
|
+
fetch: this.fetchImpl,
|
|
3186
|
+
...this.token ? { headers: { Authorization: `Bearer ${this.token}` } } : {}
|
|
3187
|
+
});
|
|
3188
|
+
}
|
|
2752
3189
|
/**
|
|
2753
3190
|
* List registered import `mapping` artifacts targeting a given object
|
|
2754
3191
|
* (framework #2611). Reads the `mapping` metadata kind via the overlay API
|
|
@@ -2793,14 +3230,10 @@ var ObjectStackAdapter = class {
|
|
|
2793
3230
|
object: spec?.object || objectName,
|
|
2794
3231
|
data: spec?.data || { provider: "object", object: objectName }
|
|
2795
3232
|
};
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
return fullSpec;
|
|
2801
|
-
} catch (err) {
|
|
2802
|
-
throw err;
|
|
2803
|
-
}
|
|
3233
|
+
const result = await this.client.meta.saveItem("view", name, fullSpec);
|
|
3234
|
+
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
3235
|
+
if (result && result.item) return result.item;
|
|
3236
|
+
return fullSpec;
|
|
2804
3237
|
}
|
|
2805
3238
|
/**
|
|
2806
3239
|
* Apply a partial update to an existing overlay view. Reads the current
|
|
@@ -2823,15 +3256,11 @@ var ObjectStackAdapter = class {
|
|
|
2823
3256
|
name: viewName,
|
|
2824
3257
|
object: current?.object || current?.data?.object || objectName
|
|
2825
3258
|
};
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
return merged;
|
|
2832
|
-
} catch (err) {
|
|
2833
|
-
throw err;
|
|
2834
|
-
}
|
|
3259
|
+
const result = await this.client.meta.saveItem("view", viewName, merged);
|
|
3260
|
+
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
3261
|
+
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
|
|
3262
|
+
if (result && result.item) return result.item;
|
|
3263
|
+
return merged;
|
|
2835
3264
|
}
|
|
2836
3265
|
/**
|
|
2837
3266
|
* Delete an overlay view (reset to artifact default if one exists, or
|
|
@@ -2840,14 +3269,10 @@ var ObjectStackAdapter = class {
|
|
|
2840
3269
|
*/
|
|
2841
3270
|
async deleteView(objectName, viewName) {
|
|
2842
3271
|
await this.connect();
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
return { deleted: !!(result?.deleted ?? result?.reset ?? true) };
|
|
2848
|
-
} catch (err) {
|
|
2849
|
-
throw err;
|
|
2850
|
-
}
|
|
3272
|
+
const result = await this.client.meta.deleteItem("view", viewName);
|
|
3273
|
+
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
3274
|
+
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
|
|
3275
|
+
return { deleted: !!(result?.deleted ?? result?.reset ?? true) };
|
|
2851
3276
|
}
|
|
2852
3277
|
/**
|
|
2853
3278
|
* Get an application definition by name or ID.
|
|
@@ -2903,19 +3328,15 @@ var ObjectStackAdapter = class {
|
|
|
2903
3328
|
*/
|
|
2904
3329
|
async updateDashboard(dashboardName, schema) {
|
|
2905
3330
|
await this.connect();
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
return result ?? void 0;
|
|
2916
|
-
} catch (err) {
|
|
2917
|
-
throw err;
|
|
2918
|
-
}
|
|
3331
|
+
const result = await this.client.meta.saveItem(
|
|
3332
|
+
"dashboard",
|
|
3333
|
+
dashboardName,
|
|
3334
|
+
schema
|
|
3335
|
+
);
|
|
3336
|
+
this.metadataCache.invalidate?.("dashboards");
|
|
3337
|
+
this.metadataCache.invalidate?.(`dashboard:${dashboardName}`);
|
|
3338
|
+
if (result && result.item) return result.item;
|
|
3339
|
+
return result ?? void 0;
|
|
2919
3340
|
}
|
|
2920
3341
|
/**
|
|
2921
3342
|
* Perform server-side aggregation via the ObjectStack analytics API.
|
|
@@ -2941,6 +3362,7 @@ var ObjectStackAdapter = class {
|
|
|
2941
3362
|
}
|
|
2942
3363
|
try {
|
|
2943
3364
|
const measureName = params.function === "count" ? "count" : `${params.field}_${params.function}`;
|
|
3365
|
+
const valueKey = this.aggregateValueKey(params);
|
|
2944
3366
|
const payload = {
|
|
2945
3367
|
cube: resource,
|
|
2946
3368
|
measures: [measureName],
|
|
@@ -2955,30 +3377,62 @@ var ObjectStackAdapter = class {
|
|
|
2955
3377
|
const measureMissing = rawRows.length > 0 && rawRows.every((row) => {
|
|
2956
3378
|
if (row == null) return true;
|
|
2957
3379
|
if (measureName in row && row[measureName] != null) return false;
|
|
2958
|
-
if (
|
|
3380
|
+
if (valueKey in row && row[valueKey] != null) return false;
|
|
2959
3381
|
return true;
|
|
2960
3382
|
});
|
|
2961
3383
|
if (measureMissing) {
|
|
2962
|
-
|
|
2963
|
-
const records = result.data || [];
|
|
2964
|
-
if (records.length === 0) return [];
|
|
2965
|
-
return this.aggregateClientSide(records, params);
|
|
3384
|
+
return await this.aggregateViaFind(resource, params);
|
|
2966
3385
|
}
|
|
2967
3386
|
return rawRows.map((row) => {
|
|
2968
3387
|
const mapped = { ...row };
|
|
2969
|
-
if (measureName !==
|
|
2970
|
-
mapped[
|
|
3388
|
+
if (measureName !== valueKey && measureName in mapped) {
|
|
3389
|
+
mapped[valueKey] = mapped[measureName];
|
|
2971
3390
|
delete mapped[measureName];
|
|
2972
3391
|
}
|
|
2973
3392
|
return mapped;
|
|
2974
3393
|
});
|
|
2975
|
-
} catch {
|
|
2976
|
-
const
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
3394
|
+
} catch (e) {
|
|
3395
|
+
const failure = classifyAnalyticsFailure(e);
|
|
3396
|
+
if (failure.kind === "rejected") {
|
|
3397
|
+
throw new AnalyticsQueryRejectedError(failure.message, failure.code);
|
|
3398
|
+
}
|
|
3399
|
+
if (failure.kind === "not-installed") {
|
|
3400
|
+
this.warnAnalyticsCapabilityOnce(failure.message);
|
|
3401
|
+
}
|
|
3402
|
+
return await this.aggregateViaFind(resource, params);
|
|
2980
3403
|
}
|
|
2981
3404
|
}
|
|
3405
|
+
/**
|
|
3406
|
+
* Client-side aggregation over a server-scoped `find()` — the fallback used
|
|
3407
|
+
* whenever the analytics endpoint cannot answer (capability absent, network
|
|
3408
|
+
* failure, or a result whose measure came back missing).
|
|
3409
|
+
*
|
|
3410
|
+
* Forwarding `params.filter` is load-bearing: without it the fallback
|
|
3411
|
+
* aggregates the whole table while the caller believes it applied a filter,
|
|
3412
|
+
* which is the "KPI silently sums everything" failure this adapter has
|
|
3413
|
+
* guarded against since the widget filter was threaded through.
|
|
3414
|
+
*/
|
|
3415
|
+
async aggregateViaFind(resource, params) {
|
|
3416
|
+
const result = await this.find(
|
|
3417
|
+
resource,
|
|
3418
|
+
params.filter ? { $filter: params.filter } : void 0
|
|
3419
|
+
);
|
|
3420
|
+
const records = result.data || [];
|
|
3421
|
+
if (records.length === 0) return [];
|
|
3422
|
+
return this.aggregateClientSide(records, params);
|
|
3423
|
+
}
|
|
3424
|
+
/**
|
|
3425
|
+
* Say "the analytics capability isn't installed" ONCE per adapter, not once
|
|
3426
|
+
* per widget: a dashboard fans out one aggregate() per KPI, and N identical
|
|
3427
|
+
* console lines read like N different failures.
|
|
3428
|
+
*/
|
|
3429
|
+
warnAnalyticsCapabilityOnce(detail) {
|
|
3430
|
+
if (this.analyticsCapabilityWarned) return;
|
|
3431
|
+
this.analyticsCapabilityWarned = true;
|
|
3432
|
+
console.warn(
|
|
3433
|
+
"[OBJECTSTACKDataSource] analytics capability unavailable \u2014 aggregating client-side from a scoped find(). Numbers stay correct but the semantic layer (cubes, joins, server-side rollups) is off. Install @objectstack/service-analytics to enable it." + (detail ? ` Server said: ${detail}` : "")
|
|
3434
|
+
);
|
|
3435
|
+
}
|
|
2982
3436
|
/**
|
|
2983
3437
|
* Run a semantic-layer `dataset` (ADR-0021) and return chart-ready rows.
|
|
2984
3438
|
*
|
|
@@ -3017,6 +3471,9 @@ var ObjectStackAdapter = class {
|
|
|
3017
3471
|
detail = errBody?.message || errBody?.error || JSON.stringify(errBody);
|
|
3018
3472
|
} catch {
|
|
3019
3473
|
}
|
|
3474
|
+
if (res.status === 501 || res.status === 404) {
|
|
3475
|
+
throw new AnalyticsNotInstalledError("POST /analytics/dataset/query", detail || void 0);
|
|
3476
|
+
}
|
|
3020
3477
|
throw new Error(`Dataset query failed: ${res.status} ${res.statusText}${detail ? ` \u2014 ${detail}` : ""}`);
|
|
3021
3478
|
}
|
|
3022
3479
|
const payload = await res.json();
|
|
@@ -3030,8 +3487,18 @@ var ObjectStackAdapter = class {
|
|
|
3030
3487
|
return { rows, fields, object, dimensionFields, drillRawRows, totals };
|
|
3031
3488
|
}
|
|
3032
3489
|
/** Client-side aggregation fallback */
|
|
3490
|
+
/**
|
|
3491
|
+
* The result column an object-bound `aggregate` projects its value under
|
|
3492
|
+
* (framework#3701, `chartAggregateValueKey` in `@objectstack/spec/ui`): the
|
|
3493
|
+
* raw `field` name — no `sum_`-style decoration, unlike a dataset measure —
|
|
3494
|
+
* or the literal `count` when a count names no field.
|
|
3495
|
+
*/
|
|
3496
|
+
aggregateValueKey(params) {
|
|
3497
|
+
return params.field || params.function || "count";
|
|
3498
|
+
}
|
|
3033
3499
|
aggregateClientSide(records, params) {
|
|
3034
3500
|
const { field, function: aggFn, groupBy } = params;
|
|
3501
|
+
const valueKey = this.aggregateValueKey(params);
|
|
3035
3502
|
const groups = {};
|
|
3036
3503
|
for (const record of records) {
|
|
3037
3504
|
const key = String(record[groupBy] ?? "Unknown");
|
|
@@ -3039,7 +3506,7 @@ var ObjectStackAdapter = class {
|
|
|
3039
3506
|
groups[key].push(record);
|
|
3040
3507
|
}
|
|
3041
3508
|
return Object.entries(groups).map(([key, group]) => {
|
|
3042
|
-
const values = group.map((r) => Number(r[field]) || 0);
|
|
3509
|
+
const values = field ? group.map((r) => Number(r[field]) || 0) : [];
|
|
3043
3510
|
let result;
|
|
3044
3511
|
switch (aggFn) {
|
|
3045
3512
|
case "count":
|
|
@@ -3059,7 +3526,7 @@ var ObjectStackAdapter = class {
|
|
|
3059
3526
|
result = values.reduce((a, b) => a + b, 0);
|
|
3060
3527
|
break;
|
|
3061
3528
|
}
|
|
3062
|
-
return { [groupBy]: key, [
|
|
3529
|
+
return { [groupBy]: key, [valueKey]: result };
|
|
3063
3530
|
});
|
|
3064
3531
|
}
|
|
3065
3532
|
/**
|
|
@@ -3262,12 +3729,16 @@ function createObjectStackAdapter(config) {
|
|
|
3262
3729
|
}
|
|
3263
3730
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3264
3731
|
0 && (module.exports = {
|
|
3732
|
+
AnalyticsNotInstalledError,
|
|
3733
|
+
AnalyticsQueryRejectedError,
|
|
3265
3734
|
AuthenticationError,
|
|
3266
3735
|
BulkOperationError,
|
|
3267
3736
|
CloudOperations,
|
|
3268
3737
|
ConcurrentUpdateError,
|
|
3269
3738
|
ConnectionError,
|
|
3739
|
+
FILTER_OPERATOR_ALIASES,
|
|
3270
3740
|
IntegrationManager,
|
|
3741
|
+
MalformedFilterError,
|
|
3271
3742
|
MetadataClient,
|
|
3272
3743
|
MetadataNotFoundError,
|
|
3273
3744
|
ObjectStackAdapter,
|
|
@@ -3275,6 +3746,7 @@ function createObjectStackAdapter(config) {
|
|
|
3275
3746
|
SecurityManager,
|
|
3276
3747
|
ValidationError,
|
|
3277
3748
|
calculateAutoLayout,
|
|
3749
|
+
classifyAnalyticsFailure,
|
|
3278
3750
|
clearSharedDiscoveryCache,
|
|
3279
3751
|
createDefaultCanvasConfig,
|
|
3280
3752
|
createErrorFromResponse,
|
|
@@ -3283,10 +3755,14 @@ function createObjectStackAdapter(config) {
|
|
|
3283
3755
|
generateContractManifest,
|
|
3284
3756
|
getSharedDiscovery,
|
|
3285
3757
|
is404Error,
|
|
3758
|
+
isAnalyticsNotInstalledError,
|
|
3286
3759
|
isConcurrentUpdateError,
|
|
3287
3760
|
isErrorType,
|
|
3761
|
+
isMalformedFilterError,
|
|
3288
3762
|
isObjectStackError,
|
|
3289
3763
|
normaliseClientError,
|
|
3764
|
+
readTransactionalBatchCapability,
|
|
3765
|
+
serializeOrderBy,
|
|
3290
3766
|
snapToGrid,
|
|
3291
3767
|
validatePluginContract
|
|
3292
3768
|
});
|