@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.js
CHANGED
|
@@ -5,7 +5,32 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
|
|
|
5
5
|
// src/index.ts
|
|
6
6
|
import { ObjectStackClient } from "@objectstack/client";
|
|
7
7
|
|
|
8
|
+
// ../types/src/index.ts
|
|
9
|
+
import {
|
|
10
|
+
defineStack,
|
|
11
|
+
ObjectStackSchema,
|
|
12
|
+
ObjectStackDefinitionSchema
|
|
13
|
+
} from "@objectstack/spec";
|
|
14
|
+
|
|
15
|
+
// ../types/src/error-code.ts
|
|
16
|
+
function errorCodeIs(err, code) {
|
|
17
|
+
const raw = err?.code;
|
|
18
|
+
if (typeof raw !== "string" || raw.length === 0) return false;
|
|
19
|
+
return raw.toUpperCase() === code.toUpperCase();
|
|
20
|
+
}
|
|
21
|
+
function errorCodeIsAnyOf(err, codes) {
|
|
22
|
+
return codes.some((c) => errorCodeIs(err, c));
|
|
23
|
+
}
|
|
24
|
+
|
|
8
25
|
// ../core/src/utils/filter-converter.ts
|
|
26
|
+
var FilterOperatorError = class extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
__publicField(this, "code", "INVALID_FILTER");
|
|
30
|
+
__publicField(this, "httpStatus", 400);
|
|
31
|
+
this.name = "FilterOperatorError";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
9
34
|
function convertOperatorToAST(operator) {
|
|
10
35
|
const operatorMap = {
|
|
11
36
|
"$eq": "=",
|
|
@@ -35,11 +60,9 @@ function convertFiltersToAST(filter) {
|
|
|
35
60
|
if (typeof value === "object" && !Array.isArray(value)) {
|
|
36
61
|
for (const [operator, operatorValue] of Object.entries(value)) {
|
|
37
62
|
if (operator === "$regex") {
|
|
38
|
-
|
|
39
|
-
`[ObjectUI]
|
|
63
|
+
throw new FilterOperatorError(
|
|
64
|
+
`[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.`
|
|
40
65
|
);
|
|
41
|
-
conditions.push([field, "contains", operatorValue]);
|
|
42
|
-
continue;
|
|
43
66
|
}
|
|
44
67
|
if (operator === "$null") {
|
|
45
68
|
conditions.push([field, operatorValue ? "is_null" : "is_not_null", true]);
|
|
@@ -53,7 +76,7 @@ function convertFiltersToAST(filter) {
|
|
|
53
76
|
if (astOperator) {
|
|
54
77
|
conditions.push([field, astOperator, operatorValue]);
|
|
55
78
|
} else {
|
|
56
|
-
throw new
|
|
79
|
+
throw new FilterOperatorError(
|
|
57
80
|
`[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.`
|
|
58
81
|
);
|
|
59
82
|
}
|
|
@@ -71,6 +94,72 @@ function convertFiltersToAST(filter) {
|
|
|
71
94
|
return ["and", ...conditions];
|
|
72
95
|
}
|
|
73
96
|
|
|
97
|
+
// ../core/src/adapters/batchTransaction.ts
|
|
98
|
+
function idOf(rec) {
|
|
99
|
+
if (rec == null) return void 0;
|
|
100
|
+
return rec.id ?? rec._id ?? rec.recordId;
|
|
101
|
+
}
|
|
102
|
+
function resolveRefs(data, results) {
|
|
103
|
+
const out = {};
|
|
104
|
+
for (const [key, value] of Object.entries(data)) {
|
|
105
|
+
if (value && typeof value === "object" && !Array.isArray(value) && typeof value.$ref === "number") {
|
|
106
|
+
const refIndex = value.$ref;
|
|
107
|
+
const id = idOf(results[refIndex]);
|
|
108
|
+
if (id === void 0) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`batchTransaction: field "${key}" references operation ${refIndex}, which has not produced an id (forward or invalid $ref)`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
out[key] = id;
|
|
114
|
+
} else {
|
|
115
|
+
out[key] = value;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
async function emulateBatchTransaction(dataSource, operations) {
|
|
121
|
+
const results = [];
|
|
122
|
+
const created = [];
|
|
123
|
+
try {
|
|
124
|
+
for (const op of operations) {
|
|
125
|
+
const action = op.action ?? "create";
|
|
126
|
+
if (action === "create") {
|
|
127
|
+
const record = await dataSource.create(
|
|
128
|
+
op.object,
|
|
129
|
+
resolveRefs(op.data ?? {}, results)
|
|
130
|
+
);
|
|
131
|
+
results.push(record);
|
|
132
|
+
const id = idOf(record);
|
|
133
|
+
if (id) created.push({ object: op.object, id });
|
|
134
|
+
} else if (action === "update") {
|
|
135
|
+
if (op.id == null) {
|
|
136
|
+
throw new Error(`batchTransaction: update on "${op.object}" requires an id`);
|
|
137
|
+
}
|
|
138
|
+
results.push(
|
|
139
|
+
await dataSource.update(
|
|
140
|
+
op.object,
|
|
141
|
+
op.id,
|
|
142
|
+
resolveRefs(op.data ?? {}, results)
|
|
143
|
+
)
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
if (op.id == null) {
|
|
147
|
+
throw new Error(`batchTransaction: delete on "${op.object}" requires an id`);
|
|
148
|
+
}
|
|
149
|
+
results.push(await dataSource.delete(op.object, op.id));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { results };
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (created.length > 0) {
|
|
155
|
+
await Promise.allSettled(
|
|
156
|
+
[...created].reverse().map((c) => dataSource.delete(c.object, c.id))
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
74
163
|
// ../core/src/utils/reference-keys.ts
|
|
75
164
|
function normalizeFieldReferenceKeys(fieldDef) {
|
|
76
165
|
if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
|
|
@@ -271,893 +360,896 @@ var MetadataCache = class {
|
|
|
271
360
|
}
|
|
272
361
|
};
|
|
273
362
|
|
|
274
|
-
// src/
|
|
275
|
-
var
|
|
363
|
+
// src/metadata-client.ts
|
|
364
|
+
var META_PREFIX = "/meta";
|
|
365
|
+
var API_PREFIX = "/api/v1";
|
|
366
|
+
function buildBase(config) {
|
|
367
|
+
const trimmed = config.baseUrl.replace(/\/+$/, "");
|
|
368
|
+
const scoped = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
|
|
369
|
+
if (/\/api\/v\d+$/i.test(trimmed)) {
|
|
370
|
+
const stripped = trimmed.replace(/\/api\/v\d+$/i, "");
|
|
371
|
+
const scopedNoApi = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
|
|
372
|
+
return `${stripped}${scopedNoApi}`;
|
|
373
|
+
}
|
|
374
|
+
return `${trimmed}${scoped}`;
|
|
375
|
+
}
|
|
376
|
+
async function parseError(res) {
|
|
377
|
+
let body;
|
|
378
|
+
try {
|
|
379
|
+
body = await res.json();
|
|
380
|
+
} catch {
|
|
381
|
+
body = await res.text().catch(() => void 0);
|
|
382
|
+
}
|
|
383
|
+
const bodyObj = body && typeof body === "object" ? body : void 0;
|
|
384
|
+
const errNode = bodyObj?.error;
|
|
385
|
+
const message = (errNode && typeof errNode === "object" ? errNode.message : errNode) ?? bodyObj?.message ?? `Metadata request failed: ${res.status} ${res.statusText}`;
|
|
386
|
+
const err = new Error(String(message));
|
|
387
|
+
err.status = res.status;
|
|
388
|
+
err.body = body;
|
|
389
|
+
const details = errNode && typeof errNode === "object" ? errNode.details : void 0;
|
|
390
|
+
const issues = details?.issues ?? (errNode && typeof errNode === "object" ? errNode.issues : void 0) ?? bodyObj?.issues;
|
|
391
|
+
if (Array.isArray(issues)) err.issues = issues;
|
|
392
|
+
const code = details?.code ?? (errNode && typeof errNode === "object" ? errNode.code : void 0) ?? bodyObj?.code;
|
|
393
|
+
if (code != null) err.code = String(code);
|
|
394
|
+
return err;
|
|
395
|
+
}
|
|
396
|
+
var MetadataClient = class _MetadataClient {
|
|
397
|
+
constructor(config) {
|
|
398
|
+
__publicField(this, "base");
|
|
399
|
+
__publicField(this, "fetchImpl");
|
|
400
|
+
__publicField(this, "headers");
|
|
401
|
+
/** ADR-0037: when true, reads render the draft-overlaid world. */
|
|
402
|
+
__publicField(this, "previewDrafts");
|
|
403
|
+
this.base = buildBase(config);
|
|
404
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
405
|
+
this.headers = { Accept: "application/json", ...config.headers ?? {} };
|
|
406
|
+
this.previewDrafts = config.previewDrafts === true;
|
|
407
|
+
}
|
|
408
|
+
/** Update the client's environment scope at runtime. */
|
|
409
|
+
withEnvironment(environmentId) {
|
|
410
|
+
return new _MetadataClient({
|
|
411
|
+
baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
|
|
412
|
+
environmentId,
|
|
413
|
+
fetch: this.fetchImpl,
|
|
414
|
+
headers: this.headers,
|
|
415
|
+
previewDrafts: this.previewDrafts
|
|
416
|
+
});
|
|
417
|
+
}
|
|
276
418
|
/**
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* @param code - Unique error code for programmatic handling
|
|
281
|
-
* @param statusCode - Optional HTTP status code
|
|
282
|
-
* @param details - Optional additional error details for debugging
|
|
419
|
+
* Derive a client whose reads render the draft-overlaid world (or not).
|
|
420
|
+
* Same base/fetch/headers; used by the app-shell to switch the whole
|
|
421
|
+
* renderer tree into ADR-0037 preview mode off one URL flag.
|
|
283
422
|
*/
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
423
|
+
withPreviewDrafts(previewDrafts) {
|
|
424
|
+
if (previewDrafts === this.previewDrafts) return this;
|
|
425
|
+
const envMatch = this.base.match(/\/api\/v\d+\/environments\/([^/]+)\/meta$/);
|
|
426
|
+
return new _MetadataClient({
|
|
427
|
+
baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
|
|
428
|
+
...envMatch ? { environmentId: decodeURIComponent(envMatch[1]) } : {},
|
|
429
|
+
fetch: this.fetchImpl,
|
|
430
|
+
headers: this.headers,
|
|
431
|
+
previewDrafts
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
/** List all registered metadata types (returns the registry rows). */
|
|
435
|
+
async listTypes() {
|
|
436
|
+
const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
437
|
+
if (!res.ok) throw await parseError(res);
|
|
438
|
+
const data = await res.json();
|
|
439
|
+
if (Array.isArray(data)) return data;
|
|
440
|
+
if (data && Array.isArray(data.entries)) return data.entries;
|
|
441
|
+
if (data && Array.isArray(data.items)) return data.items;
|
|
442
|
+
if (data && Array.isArray(data.types)) {
|
|
443
|
+
return data.types.map((t) => ({ type: t }));
|
|
444
|
+
}
|
|
445
|
+
return [];
|
|
446
|
+
}
|
|
447
|
+
/** List items of a metadata type (e.g. `object`, `field`, `view`). */
|
|
448
|
+
async list(type, options = {}) {
|
|
449
|
+
const params = [];
|
|
450
|
+
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
451
|
+
if (this.previewDrafts) params.push("preview=draft");
|
|
452
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
453
|
+
const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
|
|
454
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
455
|
+
if (!res.ok) throw await parseError(res);
|
|
456
|
+
const data = await res.json();
|
|
457
|
+
if (Array.isArray(data)) return data;
|
|
458
|
+
if (data && Array.isArray(data.items)) {
|
|
459
|
+
return data.items;
|
|
292
460
|
}
|
|
461
|
+
return [];
|
|
293
462
|
}
|
|
294
463
|
/**
|
|
295
|
-
*
|
|
464
|
+
* List pending DRAFT items (ADR-0033) — what an AI authored but nobody
|
|
465
|
+
* published yet. `list()` only sees published/active metadata, so a
|
|
466
|
+
* just-built app package looks empty there; this surfaces the drafts so the
|
|
467
|
+
* console can show a "pending changes" view and draft-aware package contents.
|
|
468
|
+
* Optionally narrow by `packageId` and/or `type`. Returns light headers
|
|
469
|
+
* (no body) carrying `packageId` for grouping.
|
|
296
470
|
*/
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
var MetadataNotFoundError = class extends ObjectStackError {
|
|
309
|
-
constructor(objectName, details) {
|
|
310
|
-
super(
|
|
311
|
-
`Metadata not found for object: ${objectName}`,
|
|
312
|
-
"METADATA_NOT_FOUND",
|
|
313
|
-
404,
|
|
314
|
-
{ objectName, ...details }
|
|
315
|
-
);
|
|
316
|
-
this.name = "MetadataNotFoundError";
|
|
471
|
+
async listDrafts(options = {}) {
|
|
472
|
+
const params = [];
|
|
473
|
+
if (options.packageId) params.push(`packageId=${encodeURIComponent(options.packageId)}`);
|
|
474
|
+
if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
|
|
475
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
476
|
+
const url = `${this.base}/_drafts${qs}`;
|
|
477
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
478
|
+
if (!res.ok) throw await parseError(res);
|
|
479
|
+
const data = await res.json();
|
|
480
|
+
if (Array.isArray(data)) return data;
|
|
481
|
+
return data?.drafts ?? data?.data?.drafts ?? [];
|
|
317
482
|
}
|
|
318
|
-
};
|
|
319
|
-
var BulkOperationError = class extends ObjectStackError {
|
|
320
483
|
/**
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
* @param successCount - Number of successful operations
|
|
325
|
-
* @param failureCount - Number of failed operations
|
|
326
|
-
* @param errors - Array of individual errors
|
|
327
|
-
* @param details - Additional error details
|
|
484
|
+
* Get a single metadata item. Returns the unwrapped item content
|
|
485
|
+
* (matching the framework REST handler which calls `res.json(item)`).
|
|
486
|
+
* Returns `null` on 404 to keep the call-site ergonomic.
|
|
328
487
|
*/
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
341
|
-
);
|
|
342
|
-
__publicField(this, "successCount", successCount);
|
|
343
|
-
__publicField(this, "failureCount", failureCount);
|
|
344
|
-
__publicField(this, "errors", errors);
|
|
345
|
-
this.name = "BulkOperationError";
|
|
488
|
+
async get(type, name, options = {}) {
|
|
489
|
+
const params = [];
|
|
490
|
+
if (options.state === "draft") params.push("state=draft");
|
|
491
|
+
else if (this.previewDrafts) params.push("preview=draft");
|
|
492
|
+
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
493
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
494
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
495
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
496
|
+
if (res.status === 404) return null;
|
|
497
|
+
if (!res.ok) throw await parseError(res);
|
|
498
|
+
return await res.json();
|
|
346
499
|
}
|
|
347
500
|
/**
|
|
348
|
-
*
|
|
501
|
+
* Read the pending draft body for an item (`?state=draft`). Returns
|
|
502
|
+
* `null` when there is no draft pending. Draft reads do NOT fall
|
|
503
|
+
* back to the published overlay or the artifact registry — a `null`
|
|
504
|
+
* unambiguously means "nothing to publish".
|
|
505
|
+
*
|
|
506
|
+
* Note: the framework wraps draft responses in an envelope
|
|
507
|
+
* `{ type, name, item }` (matching `getMetaItem`); callers should
|
|
508
|
+
* read `.item` to get the body. The legacy `get()` returns the
|
|
509
|
+
* unwrapped body, so this method preserves that asymmetry by
|
|
510
|
+
* returning whatever the server sent.
|
|
349
511
|
*/
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const failureRate = total > 0 ? this.failureCount / total : 0;
|
|
353
|
-
return {
|
|
354
|
-
operation: this.details?.operation,
|
|
355
|
-
total,
|
|
356
|
-
successful: this.successCount,
|
|
357
|
-
failed: this.failureCount,
|
|
358
|
-
failureRate,
|
|
359
|
-
errors: this.errors
|
|
360
|
-
};
|
|
512
|
+
async getDraft(type, name, options = {}) {
|
|
513
|
+
return this.get(type, name, { state: "draft", ...options.packageId ? { packageId: options.packageId } : {} });
|
|
361
514
|
}
|
|
362
|
-
};
|
|
363
|
-
var ConnectionError = class extends ObjectStackError {
|
|
364
|
-
constructor(message, url, details, statusCode) {
|
|
365
|
-
super(
|
|
366
|
-
`Connection error: ${message}`,
|
|
367
|
-
"CONNECTION_ERROR",
|
|
368
|
-
statusCode || 503,
|
|
369
|
-
{ url, ...details }
|
|
370
|
-
);
|
|
371
|
-
__publicField(this, "url", url);
|
|
372
|
-
this.name = "ConnectionError";
|
|
373
|
-
}
|
|
374
|
-
};
|
|
375
|
-
var AuthenticationError = class extends ObjectStackError {
|
|
376
|
-
constructor(message = "Authentication failed", details, statusCode) {
|
|
377
|
-
super(
|
|
378
|
-
message,
|
|
379
|
-
"AUTHENTICATION_ERROR",
|
|
380
|
-
statusCode || 401,
|
|
381
|
-
details
|
|
382
|
-
);
|
|
383
|
-
this.name = "AuthenticationError";
|
|
384
|
-
}
|
|
385
|
-
};
|
|
386
|
-
var ValidationError = class extends ObjectStackError {
|
|
387
515
|
/**
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
* @param validationErrors - Array of validation error details
|
|
393
|
-
* @param details - Additional error details
|
|
516
|
+
* Save (PUT) a metadata item. The framework accepts both the bare
|
|
517
|
+
* item payload and the `{ item: ... }` / `{ metadata: ... }`
|
|
518
|
+
* envelopes; we send bare for consistency. Pass `mode: 'draft'` to
|
|
519
|
+
* stage the change without publishing (Studio's "Save" button).
|
|
394
520
|
*/
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
);
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
521
|
+
async save(type, name, item, options = {}) {
|
|
522
|
+
if (!name || !String(name).trim()) {
|
|
523
|
+
throw new Error(
|
|
524
|
+
`MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
const params = [];
|
|
528
|
+
if (options.force) params.push("force=true");
|
|
529
|
+
if (options.mode === "draft") params.push("mode=draft");
|
|
530
|
+
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
531
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
532
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
533
|
+
const headers = {
|
|
534
|
+
...this.headers,
|
|
535
|
+
"Content-Type": "application/json"
|
|
536
|
+
};
|
|
537
|
+
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
538
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
539
|
+
const res = await this.fetchImpl(url, {
|
|
540
|
+
method: "PUT",
|
|
541
|
+
headers,
|
|
542
|
+
body: JSON.stringify(item)
|
|
543
|
+
});
|
|
544
|
+
if (!res.ok) throw await parseError(res);
|
|
545
|
+
return await res.json();
|
|
409
546
|
}
|
|
410
547
|
/**
|
|
411
|
-
*
|
|
548
|
+
* Publish a single pending draft BY REFERENCE — promotes the draft row for
|
|
549
|
+
* `(type, name)` into the active overlay and drops the draft. Works for ANY
|
|
550
|
+
* draft, including ones with no `packageId` binding (which the package-scoped
|
|
551
|
+
* `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
|
|
552
|
+
* exact set returned by {@link listDrafts} without needing a package.
|
|
553
|
+
*
|
|
554
|
+
* Returns the server result. For `seed` drafts the protocol also materializes
|
|
555
|
+
* the rows and reports under `seedApplied` — a data problem never fails the
|
|
556
|
+
* publish, so callers should check `seedApplied?.success` and warn the user
|
|
557
|
+
* rather than assume the data went live.
|
|
412
558
|
*/
|
|
413
|
-
|
|
414
|
-
|
|
559
|
+
async publishDraft(type, name) {
|
|
560
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
|
|
561
|
+
const res = await this.fetchImpl(url, {
|
|
562
|
+
method: "POST",
|
|
563
|
+
headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
|
|
564
|
+
body: "{}"
|
|
565
|
+
});
|
|
566
|
+
if (!res.ok) throw await parseError(res);
|
|
567
|
+
const body = await res.json().catch(() => ({}));
|
|
568
|
+
const inner = body?.data && typeof body.data === "object" ? body.data : body;
|
|
569
|
+
return inner;
|
|
415
570
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
571
|
+
/**
|
|
572
|
+
* Get the 3-state layered view of a metadata item (Phase 3a). Returns
|
|
573
|
+
* `code` (the artifact / fallback default), `overlay` (the saved
|
|
574
|
+
* customisation, if any), and `effective` (what the runtime sees).
|
|
575
|
+
*/
|
|
576
|
+
async layered(type, name, options = {}) {
|
|
577
|
+
const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : "";
|
|
578
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
|
|
579
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
580
|
+
if (res.status === 404) {
|
|
581
|
+
return { code: null, overlay: null, overlayScope: null, effective: null };
|
|
426
582
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
function isErrorType(error, errorClass) {
|
|
458
|
-
return error instanceof errorClass;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// src/cloud.ts
|
|
462
|
-
var CloudOperations = class {
|
|
463
|
-
constructor(getClient) {
|
|
464
|
-
__publicField(this, "getClient", getClient);
|
|
583
|
+
if (!res.ok) throw await parseError(res);
|
|
584
|
+
const body = await res.json();
|
|
585
|
+
const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
|
|
586
|
+
if (!hasEnvelope) {
|
|
587
|
+
return {
|
|
588
|
+
code: null,
|
|
589
|
+
overlay: null,
|
|
590
|
+
overlayScope: null,
|
|
591
|
+
effective: body
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
code: body.code ?? null,
|
|
596
|
+
overlay: body.overlay ?? null,
|
|
597
|
+
overlayScope: body.overlayScope ?? null,
|
|
598
|
+
effective: body.effective ?? null,
|
|
599
|
+
...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
|
|
600
|
+
// ADR-0010 Phase 4 — pass through the metadata-protection envelope so
|
|
601
|
+
// the editor can render lock affordances without a second round trip.
|
|
602
|
+
...body.lock !== void 0 ? { lock: body.lock } : {},
|
|
603
|
+
...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
|
|
604
|
+
...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
|
|
605
|
+
...body.lockDocsUrl !== void 0 ? { lockDocsUrl: body.lockDocsUrl } : {},
|
|
606
|
+
...body.provenance !== void 0 ? { provenance: body.provenance } : {},
|
|
607
|
+
...body.packageId !== void 0 ? { packageId: body.packageId } : {},
|
|
608
|
+
...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
|
|
609
|
+
...body.editable !== void 0 ? { editable: body.editable } : {},
|
|
610
|
+
...body.deletable !== void 0 ? { deletable: body.deletable } : {},
|
|
611
|
+
...body.resettable !== void 0 ? { resettable: body.resettable } : {}
|
|
612
|
+
};
|
|
465
613
|
}
|
|
466
614
|
/**
|
|
467
|
-
*
|
|
615
|
+
* Cross-type sweep of load-time validation results — calls
|
|
616
|
+
* `GET /meta/diagnostics`. Returns every entry the framework
|
|
617
|
+
* considers invalid (or, when `severity: 'warning'`, also entries
|
|
618
|
+
* with only warnings).
|
|
619
|
+
*
|
|
620
|
+
* Used by the Studio's governance overview page and by the
|
|
621
|
+
* directory page to show "N invalid" badges per metadata type.
|
|
468
622
|
*/
|
|
469
|
-
async
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
623
|
+
async diagnostics(options = {}) {
|
|
624
|
+
const params = [];
|
|
625
|
+
if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
|
|
626
|
+
if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
|
|
627
|
+
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
628
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
629
|
+
const url = `${this.base}/diagnostics${qs}`;
|
|
630
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
631
|
+
if (res.status === 404) {
|
|
632
|
+
return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
|
|
633
|
+
}
|
|
634
|
+
if (!res.ok) throw await parseError(res);
|
|
635
|
+
const data = await res.json();
|
|
636
|
+
return {
|
|
637
|
+
entries: Array.isArray(data.entries) ? data.entries : [],
|
|
638
|
+
total: typeof data.total === "number" ? data.total : 0,
|
|
639
|
+
scannedTypes: typeof data.scannedTypes === "number" ? data.scannedTypes : 0,
|
|
640
|
+
scannedItems: typeof data.scannedItems === "number" ? data.scannedItems : 0,
|
|
641
|
+
stats: data.stats && typeof data.stats === "object" ? data.stats : {}
|
|
642
|
+
};
|
|
473
643
|
}
|
|
474
644
|
/**
|
|
475
|
-
*
|
|
645
|
+
* Find every metadata item that references this one (Phase 3a). Useful
|
|
646
|
+
* for pre-delete impact analysis: "Are any views pointing at this
|
|
647
|
+
* object before I drop it?".
|
|
476
648
|
*/
|
|
477
|
-
async
|
|
478
|
-
const
|
|
479
|
-
const
|
|
480
|
-
|
|
649
|
+
async references(type, name) {
|
|
650
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
|
|
651
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
652
|
+
if (res.status === 404) return [];
|
|
653
|
+
if (!res.ok) throw await parseError(res);
|
|
654
|
+
const data = await res.json();
|
|
655
|
+
return Array.isArray(data) ? data : data?.items ?? [];
|
|
481
656
|
}
|
|
482
657
|
/**
|
|
483
|
-
*
|
|
658
|
+
* Reset a metadata customization overlay back to the artifact default.
|
|
659
|
+
* Idempotent: returns the result even when no overlay row existed.
|
|
660
|
+
* Pass `state: 'draft'` to discard the pending draft only (keeps the
|
|
661
|
+
* published overlay intact) — useful for a Studio "Discard draft" button.
|
|
484
662
|
*/
|
|
485
|
-
async
|
|
486
|
-
const
|
|
487
|
-
const
|
|
488
|
-
|
|
663
|
+
async reset(type, name, options = {}) {
|
|
664
|
+
const qs = options.state === "draft" ? "?state=draft" : "";
|
|
665
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
666
|
+
const headers = { ...this.headers };
|
|
667
|
+
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
668
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
669
|
+
const res = await this.fetchImpl(url, { method: "DELETE", headers });
|
|
670
|
+
if (!res.ok) throw await parseError(res);
|
|
671
|
+
return await res.json();
|
|
489
672
|
}
|
|
490
673
|
/**
|
|
491
|
-
*
|
|
674
|
+
* Promote the pending draft of an item to the active overlay
|
|
675
|
+
* (POST `/meta/:type/:name/publish`). Returns
|
|
676
|
+
* `{ success, version, seq, message }`. Throws a `404 no_draft` if
|
|
677
|
+
* nothing is pending and `409 metadata_conflict` if the published
|
|
678
|
+
* overlay moved while the draft was sitting.
|
|
492
679
|
*/
|
|
493
|
-
async
|
|
494
|
-
const
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
};
|
|
499
|
-
|
|
500
|
-
// src/contracts.ts
|
|
501
|
-
function validatePluginContract(contract) {
|
|
502
|
-
const errors = [];
|
|
503
|
-
const warnings = [];
|
|
504
|
-
if (!contract.name || contract.name.trim().length === 0) {
|
|
505
|
-
errors.push({ field: "name", message: "Plugin name is required", code: "MISSING_NAME" });
|
|
506
|
-
}
|
|
507
|
-
if (!contract.version || !/^\d+\.\d+\.\d+/.test(contract.version)) {
|
|
508
|
-
errors.push({ field: "version", message: "Valid semver version is required", code: "INVALID_VERSION" });
|
|
509
|
-
}
|
|
510
|
-
if (!contract.exports || contract.exports.length === 0) {
|
|
511
|
-
errors.push({ field: "exports", message: "At least one export is required", code: "NO_EXPORTS" });
|
|
512
|
-
}
|
|
513
|
-
if (contract.exports) {
|
|
514
|
-
const validTypes = ["component", "hook", "utility", "provider"];
|
|
515
|
-
for (const exp of contract.exports) {
|
|
516
|
-
if (!exp.name) {
|
|
517
|
-
errors.push({ field: "exports.name", message: "Export name is required", code: "MISSING_EXPORT_NAME" });
|
|
518
|
-
}
|
|
519
|
-
if (!validTypes.includes(exp.type)) {
|
|
520
|
-
errors.push({ field: "exports.type", message: `Invalid export type: ${exp.type}`, code: "INVALID_EXPORT_TYPE" });
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
if (!contract.permissions || contract.permissions.length === 0) {
|
|
525
|
-
warnings.push("No permissions declared \u2014 plugin will have minimal access");
|
|
526
|
-
}
|
|
527
|
-
return { valid: errors.length === 0, errors, warnings };
|
|
528
|
-
}
|
|
529
|
-
function generateContractManifest(contract) {
|
|
530
|
-
return {
|
|
531
|
-
$schema: "https://objectui.org/schemas/plugin-contract-v1.json",
|
|
532
|
-
name: contract.name,
|
|
533
|
-
version: contract.version,
|
|
534
|
-
peerDependencies: contract.peerDependencies ?? {},
|
|
535
|
-
exports: contract.exports.map((exp) => ({
|
|
536
|
-
name: exp.name,
|
|
537
|
-
type: exp.type,
|
|
538
|
-
description: exp.description ?? ""
|
|
539
|
-
})),
|
|
540
|
-
permissions: contract.permissions ?? [],
|
|
541
|
-
api: contract.api ?? {},
|
|
542
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
543
|
-
};
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// src/userState.ts
|
|
547
|
-
var DEFAULT_RESOURCE = "sys_user_preference";
|
|
548
|
-
function createObjectStackUserStateAdapter(options) {
|
|
549
|
-
const {
|
|
550
|
-
dataSource,
|
|
551
|
-
userId,
|
|
552
|
-
key,
|
|
553
|
-
resource = DEFAULT_RESOURCE,
|
|
554
|
-
onError = () => {
|
|
555
|
-
}
|
|
556
|
-
} = options;
|
|
557
|
-
let cachedRowId = null;
|
|
558
|
-
let saveChain = Promise.resolve();
|
|
559
|
-
const findExisting = async () => {
|
|
560
|
-
const params = {
|
|
561
|
-
$filter: {
|
|
562
|
-
user_id: userId,
|
|
563
|
-
key
|
|
564
|
-
},
|
|
565
|
-
$top: 1
|
|
680
|
+
async publish(type, name, options = {}) {
|
|
681
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
|
|
682
|
+
const headers = {
|
|
683
|
+
...this.headers,
|
|
684
|
+
"Content-Type": "application/json"
|
|
566
685
|
};
|
|
567
|
-
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
const keyMismatch = r.key !== void 0 && r.key !== key;
|
|
573
|
-
return !userMismatch && !keyMismatch;
|
|
686
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
687
|
+
const res = await this.fetchImpl(url, {
|
|
688
|
+
method: "POST",
|
|
689
|
+
headers,
|
|
690
|
+
body: JSON.stringify(options.message ? { message: options.message } : {})
|
|
574
691
|
});
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
const decodeValue = (value) => {
|
|
578
|
-
if (Array.isArray(value)) return value;
|
|
579
|
-
if (typeof value === "string") {
|
|
580
|
-
try {
|
|
581
|
-
const parsed = JSON.parse(value);
|
|
582
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
583
|
-
} catch {
|
|
584
|
-
return [];
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
return [];
|
|
588
|
-
};
|
|
589
|
-
const upsert = async (items) => {
|
|
590
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
591
|
-
if (cachedRowId !== null) {
|
|
592
|
-
try {
|
|
593
|
-
await dataSource.update(resource, cachedRowId, { value: items, updated_at: now });
|
|
594
|
-
return;
|
|
595
|
-
} catch (updateError) {
|
|
596
|
-
cachedRowId = null;
|
|
597
|
-
onError("save", updateError);
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
const existing = await findExisting();
|
|
601
|
-
if (existing && existing.id !== void 0 && existing.id !== null) {
|
|
602
|
-
cachedRowId = existing.id;
|
|
603
|
-
await dataSource.update(resource, existing.id, { value: items, updated_at: now });
|
|
604
|
-
return;
|
|
605
|
-
}
|
|
606
|
-
try {
|
|
607
|
-
const created = await dataSource.create(resource, {
|
|
608
|
-
user_id: userId,
|
|
609
|
-
key,
|
|
610
|
-
value: items,
|
|
611
|
-
updated_at: now
|
|
612
|
-
});
|
|
613
|
-
const newId = created?.id;
|
|
614
|
-
if (newId !== void 0 && newId !== null) cachedRowId = newId;
|
|
615
|
-
} catch (createError) {
|
|
616
|
-
const recovered = await findExisting();
|
|
617
|
-
if (recovered && recovered.id !== void 0 && recovered.id !== null) {
|
|
618
|
-
cachedRowId = recovered.id;
|
|
619
|
-
await dataSource.update(resource, recovered.id, { value: items, updated_at: now });
|
|
620
|
-
return;
|
|
621
|
-
}
|
|
622
|
-
throw createError;
|
|
623
|
-
}
|
|
624
|
-
};
|
|
625
|
-
return {
|
|
626
|
-
async load() {
|
|
627
|
-
try {
|
|
628
|
-
const row = await findExisting();
|
|
629
|
-
if (!row) {
|
|
630
|
-
cachedRowId = null;
|
|
631
|
-
return [];
|
|
632
|
-
}
|
|
633
|
-
if (row.id !== void 0 && row.id !== null) cachedRowId = row.id;
|
|
634
|
-
return decodeValue(row.value);
|
|
635
|
-
} catch (error) {
|
|
636
|
-
onError("load", error);
|
|
637
|
-
return [];
|
|
638
|
-
}
|
|
639
|
-
},
|
|
640
|
-
async save(items) {
|
|
641
|
-
const run = saveChain.then(() => upsert(items)).catch((error) => {
|
|
642
|
-
onError("save", error);
|
|
643
|
-
});
|
|
644
|
-
saveChain = run;
|
|
645
|
-
return run;
|
|
646
|
-
}
|
|
647
|
-
};
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// src/integration.ts
|
|
651
|
-
var IntegrationManager = class {
|
|
652
|
-
constructor() {
|
|
653
|
-
__publicField(this, "integrations", /* @__PURE__ */ new Map());
|
|
692
|
+
if (!res.ok) throw await parseError(res);
|
|
693
|
+
return await res.json();
|
|
654
694
|
}
|
|
655
695
|
/**
|
|
656
|
-
*
|
|
696
|
+
* Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
|
|
697
|
+
* The server reads the history row at `toVersion`, writes its body back
|
|
698
|
+
* as the active overlay with `operation_type='revert'`. Returns
|
|
699
|
+
* `{ success, version, seq, restoredFromVersion, message }`. Throws
|
|
700
|
+
* `404 version_not_found` for unknown versions and `409 version_not_restorable`
|
|
701
|
+
* when the target version is a delete tombstone.
|
|
657
702
|
*/
|
|
658
|
-
|
|
659
|
-
this.
|
|
703
|
+
async rollback(type, name, toVersion, options = {}) {
|
|
704
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
|
|
705
|
+
const headers = {
|
|
706
|
+
...this.headers,
|
|
707
|
+
"Content-Type": "application/json"
|
|
708
|
+
};
|
|
709
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
710
|
+
const res = await this.fetchImpl(url, {
|
|
711
|
+
method: "POST",
|
|
712
|
+
headers,
|
|
713
|
+
body: JSON.stringify({
|
|
714
|
+
toVersion,
|
|
715
|
+
...options.message ? { message: options.message } : {}
|
|
716
|
+
})
|
|
717
|
+
});
|
|
718
|
+
if (!res.ok) throw await parseError(res);
|
|
719
|
+
return await res.json();
|
|
660
720
|
}
|
|
661
721
|
/**
|
|
662
|
-
*
|
|
722
|
+
* Compute a structured top-level key diff between two history versions
|
|
723
|
+
* (GET `/meta/:type/:name/diff?from=&to=`). Returns
|
|
724
|
+
* `{ added, removed, changed }` where each entry carries a `path` and
|
|
725
|
+
* the relevant value(s). Useful for "what changed in this draft?" and
|
|
726
|
+
* pre-rollback previews.
|
|
663
727
|
*/
|
|
664
|
-
|
|
665
|
-
|
|
728
|
+
async diff(type, name, fromVersion, toVersion) {
|
|
729
|
+
const params = new URLSearchParams();
|
|
730
|
+
if (fromVersion !== void 0) params.set("from", String(fromVersion));
|
|
731
|
+
if (toVersion !== void 0) params.set("to", String(toVersion));
|
|
732
|
+
const qs = params.toString();
|
|
733
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ""}`;
|
|
734
|
+
const res = await this.fetchImpl(url, {
|
|
735
|
+
method: "GET",
|
|
736
|
+
headers: this.headers,
|
|
737
|
+
cache: "no-store"
|
|
738
|
+
});
|
|
739
|
+
if (!res.ok) throw await parseError(res);
|
|
740
|
+
return await res.json();
|
|
666
741
|
}
|
|
667
|
-
/**
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
742
|
+
/** Fetch the durable history (change log) for one metadata item. */
|
|
743
|
+
async history(type, name, options = {}) {
|
|
744
|
+
const params = new URLSearchParams();
|
|
745
|
+
if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
|
|
746
|
+
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
747
|
+
const qs = params.toString();
|
|
748
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
|
|
749
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
750
|
+
if (!res.ok) throw await parseError(res);
|
|
751
|
+
return await res.json();
|
|
672
752
|
}
|
|
673
753
|
/**
|
|
674
|
-
*
|
|
754
|
+
* Fetch the protection-audit trail for one metadata item
|
|
755
|
+
* (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
|
|
756
|
+
* delete/reset attempt — allowed, denied, or forced — so the
|
|
757
|
+
* Studio "审计日志 / Audit log" tab can render who tried what
|
|
758
|
+
* and whether a lock blocked it.
|
|
675
759
|
*/
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
);
|
|
760
|
+
async audit(type, name, options = {}) {
|
|
761
|
+
const params = new URLSearchParams();
|
|
762
|
+
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
763
|
+
const qs = params.toString();
|
|
764
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ""}`;
|
|
765
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
766
|
+
if (!res.ok) throw await parseError(res);
|
|
767
|
+
return await res.json();
|
|
680
768
|
}
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
// src/errors.ts
|
|
772
|
+
var ObjectStackError = class extends Error {
|
|
681
773
|
/**
|
|
682
|
-
*
|
|
683
|
-
*
|
|
774
|
+
* Create a new ObjectStackError
|
|
775
|
+
*
|
|
776
|
+
* @param message - Human-readable error message
|
|
777
|
+
* @param code - Unique error code for programmatic handling
|
|
778
|
+
* @param statusCode - Optional HTTP status code
|
|
779
|
+
* @param details - Optional additional error details for debugging
|
|
684
780
|
*/
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
} catch (err) {
|
|
694
|
-
results.push({ id, success: false, error: err.message });
|
|
695
|
-
}
|
|
781
|
+
constructor(message, code, statusCode, details) {
|
|
782
|
+
super(message);
|
|
783
|
+
__publicField(this, "code", code);
|
|
784
|
+
__publicField(this, "statusCode", statusCode);
|
|
785
|
+
__publicField(this, "details", details);
|
|
786
|
+
this.name = "ObjectStackError";
|
|
787
|
+
if (Error.captureStackTrace) {
|
|
788
|
+
Error.captureStackTrace(this, this.constructor);
|
|
696
789
|
}
|
|
697
|
-
return results;
|
|
698
790
|
}
|
|
699
791
|
/**
|
|
700
|
-
*
|
|
792
|
+
* Convert error to JSON for logging/debugging
|
|
701
793
|
*/
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
}
|
|
712
|
-
} catch (e) {
|
|
713
|
-
if (e instanceof TypeError) {
|
|
714
|
-
throw new Error(`Invalid webhook URL: ${url}`);
|
|
715
|
-
}
|
|
716
|
-
throw e;
|
|
717
|
-
}
|
|
718
|
-
await fetch(url, {
|
|
719
|
-
method: cfg.method,
|
|
720
|
-
headers: {
|
|
721
|
-
"Content-Type": "application/json",
|
|
722
|
-
...cfg.headers
|
|
723
|
-
},
|
|
724
|
-
body: JSON.stringify(payload)
|
|
725
|
-
});
|
|
726
|
-
break;
|
|
727
|
-
}
|
|
728
|
-
case "slack": {
|
|
729
|
-
const cfg = integration.config;
|
|
730
|
-
const url = cfg.webhookUrl;
|
|
731
|
-
try {
|
|
732
|
-
const parsed = new URL(url);
|
|
733
|
-
if (parsed.protocol !== "https:") {
|
|
734
|
-
throw new Error(`Invalid Slack webhook URL protocol: ${parsed.protocol}`);
|
|
735
|
-
}
|
|
736
|
-
} catch (e) {
|
|
737
|
-
if (e instanceof TypeError) {
|
|
738
|
-
throw new Error(`Invalid Slack webhook URL: ${url}`);
|
|
739
|
-
}
|
|
740
|
-
throw e;
|
|
741
|
-
}
|
|
742
|
-
await fetch(url, {
|
|
743
|
-
method: "POST",
|
|
744
|
-
headers: { "Content-Type": "application/json" },
|
|
745
|
-
body: JSON.stringify({
|
|
746
|
-
channel: cfg.channel,
|
|
747
|
-
username: cfg.username,
|
|
748
|
-
icon_emoji: cfg.iconEmoji,
|
|
749
|
-
text: JSON.stringify(payload)
|
|
750
|
-
})
|
|
751
|
-
});
|
|
752
|
-
break;
|
|
753
|
-
}
|
|
754
|
-
// Email and other providers would require server-side implementation
|
|
755
|
-
default:
|
|
756
|
-
break;
|
|
757
|
-
}
|
|
794
|
+
toJSON() {
|
|
795
|
+
return {
|
|
796
|
+
name: this.name,
|
|
797
|
+
message: this.message,
|
|
798
|
+
code: this.code,
|
|
799
|
+
statusCode: this.statusCode,
|
|
800
|
+
details: this.details,
|
|
801
|
+
stack: this.stack
|
|
802
|
+
};
|
|
758
803
|
}
|
|
759
804
|
};
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
const scopedNoApi = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
|
|
770
|
-
return `${stripped}${scopedNoApi}`;
|
|
771
|
-
}
|
|
772
|
-
return `${trimmed}${scoped}`;
|
|
773
|
-
}
|
|
774
|
-
async function parseError(res) {
|
|
775
|
-
let body;
|
|
776
|
-
try {
|
|
777
|
-
body = await res.json();
|
|
778
|
-
} catch {
|
|
779
|
-
body = await res.text().catch(() => void 0);
|
|
780
|
-
}
|
|
781
|
-
const bodyObj = body && typeof body === "object" ? body : void 0;
|
|
782
|
-
const errNode = bodyObj?.error;
|
|
783
|
-
const message = (errNode && typeof errNode === "object" ? errNode.message : errNode) ?? bodyObj?.message ?? `Metadata request failed: ${res.status} ${res.statusText}`;
|
|
784
|
-
const err = new Error(String(message));
|
|
785
|
-
err.status = res.status;
|
|
786
|
-
err.body = body;
|
|
787
|
-
const details = errNode && typeof errNode === "object" ? errNode.details : void 0;
|
|
788
|
-
const issues = details?.issues ?? (errNode && typeof errNode === "object" ? errNode.issues : void 0) ?? bodyObj?.issues;
|
|
789
|
-
if (Array.isArray(issues)) err.issues = issues;
|
|
790
|
-
const code = details?.code ?? (errNode && typeof errNode === "object" ? errNode.code : void 0) ?? bodyObj?.code;
|
|
791
|
-
if (code != null) err.code = String(code);
|
|
792
|
-
return err;
|
|
793
|
-
}
|
|
794
|
-
var MetadataClient = class _MetadataClient {
|
|
795
|
-
constructor(config) {
|
|
796
|
-
__publicField(this, "base");
|
|
797
|
-
__publicField(this, "fetchImpl");
|
|
798
|
-
__publicField(this, "headers");
|
|
799
|
-
/** ADR-0037: when true, reads render the draft-overlaid world. */
|
|
800
|
-
__publicField(this, "previewDrafts");
|
|
801
|
-
this.base = buildBase(config);
|
|
802
|
-
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
803
|
-
this.headers = { Accept: "application/json", ...config.headers ?? {} };
|
|
804
|
-
this.previewDrafts = config.previewDrafts === true;
|
|
805
|
+
var MetadataNotFoundError = class extends ObjectStackError {
|
|
806
|
+
constructor(objectName, details) {
|
|
807
|
+
super(
|
|
808
|
+
`Metadata not found for object: ${objectName}`,
|
|
809
|
+
"METADATA_NOT_FOUND",
|
|
810
|
+
404,
|
|
811
|
+
{ objectName, ...details }
|
|
812
|
+
);
|
|
813
|
+
this.name = "MetadataNotFoundError";
|
|
805
814
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
+
};
|
|
816
|
+
var BulkOperationError = class extends ObjectStackError {
|
|
817
|
+
/**
|
|
818
|
+
* Create a new BulkOperationError
|
|
819
|
+
*
|
|
820
|
+
* @param operation - The bulk operation that failed (create, update, delete)
|
|
821
|
+
* @param successCount - Number of successful operations
|
|
822
|
+
* @param failureCount - Number of failed operations
|
|
823
|
+
* @param errors - Array of individual errors
|
|
824
|
+
* @param details - Additional error details
|
|
825
|
+
*/
|
|
826
|
+
constructor(operation, successCount, failureCount, errors, details) {
|
|
827
|
+
super(
|
|
828
|
+
`Bulk ${operation} operation failed: ${successCount} succeeded, ${failureCount} failed`,
|
|
829
|
+
"BULK_OPERATION_ERROR",
|
|
830
|
+
500,
|
|
831
|
+
{
|
|
832
|
+
operation,
|
|
833
|
+
successCount,
|
|
834
|
+
failureCount,
|
|
835
|
+
errors,
|
|
836
|
+
...details
|
|
837
|
+
}
|
|
838
|
+
);
|
|
839
|
+
__publicField(this, "successCount", successCount);
|
|
840
|
+
__publicField(this, "failureCount", failureCount);
|
|
841
|
+
__publicField(this, "errors", errors);
|
|
842
|
+
this.name = "BulkOperationError";
|
|
815
843
|
}
|
|
816
844
|
/**
|
|
817
|
-
*
|
|
818
|
-
* Same base/fetch/headers; used by the app-shell to switch the whole
|
|
819
|
-
* renderer tree into ADR-0037 preview mode off one URL flag.
|
|
845
|
+
* Get a summary of the bulk operation failure
|
|
820
846
|
*/
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
const
|
|
824
|
-
return
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
847
|
+
getSummary() {
|
|
848
|
+
const total = this.successCount + this.failureCount;
|
|
849
|
+
const failureRate = total > 0 ? this.failureCount / total : 0;
|
|
850
|
+
return {
|
|
851
|
+
operation: this.details?.operation,
|
|
852
|
+
total,
|
|
853
|
+
successful: this.successCount,
|
|
854
|
+
failed: this.failureCount,
|
|
855
|
+
failureRate,
|
|
856
|
+
errors: this.errors
|
|
857
|
+
};
|
|
831
858
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
return [];
|
|
859
|
+
};
|
|
860
|
+
var ConnectionError = class extends ObjectStackError {
|
|
861
|
+
constructor(message, url, details, statusCode) {
|
|
862
|
+
super(
|
|
863
|
+
`Connection error: ${message}`,
|
|
864
|
+
"CONNECTION_ERROR",
|
|
865
|
+
statusCode || 503,
|
|
866
|
+
{ url, ...details }
|
|
867
|
+
);
|
|
868
|
+
__publicField(this, "url", url);
|
|
869
|
+
this.name = "ConnectionError";
|
|
844
870
|
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
if (Array.isArray(data)) return data;
|
|
856
|
-
if (data && Array.isArray(data.items)) {
|
|
857
|
-
return data.items;
|
|
858
|
-
}
|
|
859
|
-
return [];
|
|
871
|
+
};
|
|
872
|
+
var AuthenticationError = class extends ObjectStackError {
|
|
873
|
+
constructor(message = "Authentication failed", details, statusCode) {
|
|
874
|
+
super(
|
|
875
|
+
message,
|
|
876
|
+
"AUTHENTICATION_ERROR",
|
|
877
|
+
statusCode || 401,
|
|
878
|
+
details
|
|
879
|
+
);
|
|
880
|
+
this.name = "AuthenticationError";
|
|
860
881
|
}
|
|
882
|
+
};
|
|
883
|
+
var ValidationError = class extends ObjectStackError {
|
|
861
884
|
/**
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
*
|
|
885
|
+
* Create a new ValidationError
|
|
886
|
+
*
|
|
887
|
+
* @param message - Human-readable error message
|
|
888
|
+
* @param field - The field that failed validation (optional)
|
|
889
|
+
* @param validationErrors - Array of validation error details
|
|
890
|
+
* @param details - Additional error details
|
|
868
891
|
*/
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
892
|
+
constructor(message, field, validationErrors, details) {
|
|
893
|
+
super(
|
|
894
|
+
message,
|
|
895
|
+
"VALIDATION_ERROR",
|
|
896
|
+
400,
|
|
897
|
+
{
|
|
898
|
+
field,
|
|
899
|
+
validationErrors,
|
|
900
|
+
...details
|
|
901
|
+
}
|
|
902
|
+
);
|
|
903
|
+
__publicField(this, "field", field);
|
|
904
|
+
__publicField(this, "validationErrors", validationErrors);
|
|
905
|
+
this.name = "ValidationError";
|
|
880
906
|
}
|
|
881
907
|
/**
|
|
882
|
-
* Get
|
|
883
|
-
* (matching the framework REST handler which calls `res.json(item)`).
|
|
884
|
-
* Returns `null` on 404 to keep the call-site ergonomic.
|
|
908
|
+
* Get all validation errors as a formatted list
|
|
885
909
|
*/
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
910
|
+
getValidationErrors() {
|
|
911
|
+
return this.validationErrors || [];
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
function createErrorFromResponse(response, context) {
|
|
915
|
+
const status = response?.status || response?.statusCode || 500;
|
|
916
|
+
const message = response?.message || response?.statusText || "Unknown error";
|
|
917
|
+
const details = {
|
|
918
|
+
context,
|
|
919
|
+
response: {
|
|
920
|
+
status,
|
|
921
|
+
data: response?.data,
|
|
922
|
+
headers: response?.headers
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
switch (status) {
|
|
926
|
+
case 401:
|
|
927
|
+
return new AuthenticationError(message, details, 401);
|
|
928
|
+
case 403:
|
|
929
|
+
return new AuthenticationError(message, details, 403);
|
|
930
|
+
case 404:
|
|
931
|
+
if (context?.includes("metadata") || context?.includes("schema") || context?.includes("getObjectSchema")) {
|
|
932
|
+
const objectName = extractObjectName(context);
|
|
933
|
+
return new MetadataNotFoundError(objectName, details);
|
|
934
|
+
}
|
|
935
|
+
return new ObjectStackError(message, "NOT_FOUND", 404, details);
|
|
936
|
+
case 400:
|
|
937
|
+
return new ValidationError(message, void 0, response?.data?.errors, details);
|
|
938
|
+
case 503:
|
|
939
|
+
return new ConnectionError(message, response?.config?.url, details, 503);
|
|
940
|
+
case 504:
|
|
941
|
+
return new ConnectionError(message, response?.config?.url, details, 504);
|
|
942
|
+
default:
|
|
943
|
+
return new ObjectStackError(message, "UNKNOWN_ERROR", status, details);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
function extractObjectName(context) {
|
|
947
|
+
if (!context) return "unknown";
|
|
948
|
+
const match = context.match(/\(([^)]+)\)/);
|
|
949
|
+
return match ? match[1] : "unknown";
|
|
950
|
+
}
|
|
951
|
+
function isObjectStackError(error) {
|
|
952
|
+
return error instanceof ObjectStackError;
|
|
953
|
+
}
|
|
954
|
+
function isErrorType(error, errorClass) {
|
|
955
|
+
return error instanceof errorClass;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// src/cloud.ts
|
|
959
|
+
var CloudOperations = class {
|
|
960
|
+
constructor(getClient) {
|
|
961
|
+
__publicField(this, "getClient", getClient);
|
|
897
962
|
}
|
|
898
963
|
/**
|
|
899
|
-
*
|
|
900
|
-
* `null` when there is no draft pending. Draft reads do NOT fall
|
|
901
|
-
* back to the published overlay or the artifact registry — a `null`
|
|
902
|
-
* unambiguously means "nothing to publish".
|
|
903
|
-
*
|
|
904
|
-
* Note: the framework wraps draft responses in an envelope
|
|
905
|
-
* `{ type, name, item }` (matching `getMetaItem`); callers should
|
|
906
|
-
* read `.item` to get the body. The legacy `get()` returns the
|
|
907
|
-
* unwrapped body, so this method preserves that asymmetry by
|
|
908
|
-
* returning whatever the server sent.
|
|
964
|
+
* Deploy an application to the cloud.
|
|
909
965
|
*/
|
|
910
|
-
async
|
|
911
|
-
|
|
966
|
+
async deploy(appId, config) {
|
|
967
|
+
const client = this.getClient();
|
|
968
|
+
const result = await client.cloud?.deploy?.(appId, config);
|
|
969
|
+
return result ?? { deploymentId: `deploy-${Date.now()}`, status: "pending" };
|
|
912
970
|
}
|
|
913
971
|
/**
|
|
914
|
-
*
|
|
915
|
-
* item payload and the `{ item: ... }` / `{ metadata: ... }`
|
|
916
|
-
* envelopes; we send bare for consistency. Pass `mode: 'draft'` to
|
|
917
|
-
* stage the change without publishing (Studio's "Save" button).
|
|
972
|
+
* Get deployment status.
|
|
918
973
|
*/
|
|
919
|
-
async
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
924
|
-
const qs = params.length ? `?${params.join("&")}` : "";
|
|
925
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
926
|
-
const headers = {
|
|
927
|
-
...this.headers,
|
|
928
|
-
"Content-Type": "application/json"
|
|
929
|
-
};
|
|
930
|
-
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
931
|
-
if (options.actor) headers["X-Actor"] = options.actor;
|
|
932
|
-
const res = await this.fetchImpl(url, {
|
|
933
|
-
method: "PUT",
|
|
934
|
-
headers,
|
|
935
|
-
body: JSON.stringify(item)
|
|
936
|
-
});
|
|
937
|
-
if (!res.ok) throw await parseError(res);
|
|
938
|
-
return await res.json();
|
|
974
|
+
async getDeploymentStatus(deploymentId) {
|
|
975
|
+
const client = this.getClient();
|
|
976
|
+
const result = await client.cloud?.getDeployment?.(deploymentId);
|
|
977
|
+
return result ?? { status: "unknown" };
|
|
939
978
|
}
|
|
940
979
|
/**
|
|
941
|
-
*
|
|
942
|
-
* `(type, name)` into the active overlay and drops the draft. Works for ANY
|
|
943
|
-
* draft, including ones with no `packageId` binding (which the package-scoped
|
|
944
|
-
* `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
|
|
945
|
-
* exact set returned by {@link listDrafts} without needing a package.
|
|
946
|
-
*
|
|
947
|
-
* Returns the server result. For `seed` drafts the protocol also materializes
|
|
948
|
-
* the rows and reports under `seedApplied` — a data problem never fails the
|
|
949
|
-
* publish, so callers should check `seedApplied?.success` and warn the user
|
|
950
|
-
* rather than assume the data went live.
|
|
980
|
+
* Search marketplace entries.
|
|
951
981
|
*/
|
|
952
|
-
async
|
|
953
|
-
const
|
|
954
|
-
const
|
|
955
|
-
|
|
956
|
-
headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
|
|
957
|
-
body: "{}"
|
|
958
|
-
});
|
|
959
|
-
if (!res.ok) throw await parseError(res);
|
|
960
|
-
const body = await res.json().catch(() => ({}));
|
|
961
|
-
const inner = body?.data && typeof body.data === "object" ? body.data : body;
|
|
962
|
-
return inner;
|
|
982
|
+
async searchMarketplace(query, category) {
|
|
983
|
+
const client = this.getClient();
|
|
984
|
+
const result = await client.cloud?.marketplace?.search?.({ query, category });
|
|
985
|
+
return result?.items ?? [];
|
|
963
986
|
}
|
|
964
987
|
/**
|
|
965
|
-
*
|
|
966
|
-
* `code` (the artifact / fallback default), `overlay` (the saved
|
|
967
|
-
* customisation, if any), and `effective` (what the runtime sees).
|
|
988
|
+
* Install a marketplace plugin.
|
|
968
989
|
*/
|
|
969
|
-
async
|
|
970
|
-
const
|
|
971
|
-
const
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
990
|
+
async installPlugin(pluginId) {
|
|
991
|
+
const client = this.getClient();
|
|
992
|
+
const result = await client.cloud?.marketplace?.install?.(pluginId);
|
|
993
|
+
return result ?? { success: false };
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
// src/contracts.ts
|
|
998
|
+
function validatePluginContract(contract) {
|
|
999
|
+
const errors = [];
|
|
1000
|
+
const warnings = [];
|
|
1001
|
+
if (!contract.name || contract.name.trim().length === 0) {
|
|
1002
|
+
errors.push({ field: "name", message: "Plugin name is required", code: "MISSING_NAME" });
|
|
1003
|
+
}
|
|
1004
|
+
if (!contract.version || !/^\d+\.\d+\.\d+/.test(contract.version)) {
|
|
1005
|
+
errors.push({ field: "version", message: "Valid semver version is required", code: "INVALID_VERSION" });
|
|
1006
|
+
}
|
|
1007
|
+
if (!contract.exports || contract.exports.length === 0) {
|
|
1008
|
+
errors.push({ field: "exports", message: "At least one export is required", code: "NO_EXPORTS" });
|
|
1009
|
+
}
|
|
1010
|
+
if (contract.exports) {
|
|
1011
|
+
const validTypes = ["component", "hook", "utility", "provider"];
|
|
1012
|
+
for (const exp of contract.exports) {
|
|
1013
|
+
if (!exp.name) {
|
|
1014
|
+
errors.push({ field: "exports.name", message: "Export name is required", code: "MISSING_EXPORT_NAME" });
|
|
1015
|
+
}
|
|
1016
|
+
if (!validTypes.includes(exp.type)) {
|
|
1017
|
+
errors.push({ field: "exports.type", message: `Invalid export type: ${exp.type}`, code: "INVALID_EXPORT_TYPE" });
|
|
1018
|
+
}
|
|
975
1019
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1020
|
+
}
|
|
1021
|
+
if (!contract.permissions || contract.permissions.length === 0) {
|
|
1022
|
+
warnings.push("No permissions declared \u2014 plugin will have minimal access");
|
|
1023
|
+
}
|
|
1024
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
1025
|
+
}
|
|
1026
|
+
function generateContractManifest(contract) {
|
|
1027
|
+
return {
|
|
1028
|
+
$schema: "https://objectui.org/schemas/plugin-contract-v1.json",
|
|
1029
|
+
name: contract.name,
|
|
1030
|
+
version: contract.version,
|
|
1031
|
+
peerDependencies: contract.peerDependencies ?? {},
|
|
1032
|
+
exports: contract.exports.map((exp) => ({
|
|
1033
|
+
name: exp.name,
|
|
1034
|
+
type: exp.type,
|
|
1035
|
+
description: exp.description ?? ""
|
|
1036
|
+
})),
|
|
1037
|
+
permissions: contract.permissions ?? [],
|
|
1038
|
+
api: contract.api ?? {},
|
|
1039
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// src/userState.ts
|
|
1044
|
+
var DEFAULT_RESOURCE = "sys_user_preference";
|
|
1045
|
+
function createObjectStackUserStateAdapter(options) {
|
|
1046
|
+
const {
|
|
1047
|
+
dataSource,
|
|
1048
|
+
userId,
|
|
1049
|
+
key,
|
|
1050
|
+
resource = DEFAULT_RESOURCE,
|
|
1051
|
+
onError = () => {
|
|
986
1052
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
|
|
998
|
-
...body.lockDocsUrl !== void 0 ? { lockDocsUrl: body.lockDocsUrl } : {},
|
|
999
|
-
...body.provenance !== void 0 ? { provenance: body.provenance } : {},
|
|
1000
|
-
...body.packageId !== void 0 ? { packageId: body.packageId } : {},
|
|
1001
|
-
...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
|
|
1002
|
-
...body.editable !== void 0 ? { editable: body.editable } : {},
|
|
1003
|
-
...body.deletable !== void 0 ? { deletable: body.deletable } : {},
|
|
1004
|
-
...body.resettable !== void 0 ? { resettable: body.resettable } : {}
|
|
1053
|
+
} = options;
|
|
1054
|
+
let cachedRowId = null;
|
|
1055
|
+
let saveChain = Promise.resolve();
|
|
1056
|
+
const findExisting = async () => {
|
|
1057
|
+
const params = {
|
|
1058
|
+
$filter: {
|
|
1059
|
+
user_id: userId,
|
|
1060
|
+
key
|
|
1061
|
+
},
|
|
1062
|
+
$top: 1
|
|
1005
1063
|
};
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
if (
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
|
|
1064
|
+
const result = await dataSource.find(resource, params);
|
|
1065
|
+
const rows = result?.data ?? [];
|
|
1066
|
+
const match = rows.find((r) => {
|
|
1067
|
+
if (!r) return false;
|
|
1068
|
+
const userMismatch = r.user_id !== void 0 && r.user_id !== userId;
|
|
1069
|
+
const keyMismatch = r.key !== void 0 && r.key !== key;
|
|
1070
|
+
return !userMismatch && !keyMismatch;
|
|
1071
|
+
});
|
|
1072
|
+
return match ?? null;
|
|
1073
|
+
};
|
|
1074
|
+
const decodeValue = (value) => {
|
|
1075
|
+
if (Array.isArray(value)) return value;
|
|
1076
|
+
if (typeof value === "string") {
|
|
1077
|
+
try {
|
|
1078
|
+
const parsed = JSON.parse(value);
|
|
1079
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1080
|
+
} catch {
|
|
1081
|
+
return [];
|
|
1082
|
+
}
|
|
1026
1083
|
}
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1084
|
+
return [];
|
|
1085
|
+
};
|
|
1086
|
+
const upsert = async (items) => {
|
|
1087
|
+
if (cachedRowId !== null) {
|
|
1088
|
+
try {
|
|
1089
|
+
await dataSource.update(resource, cachedRowId, { value: items });
|
|
1090
|
+
return;
|
|
1091
|
+
} catch (updateError) {
|
|
1092
|
+
cachedRowId = null;
|
|
1093
|
+
onError("save", updateError);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
const existing = await findExisting();
|
|
1097
|
+
if (existing && existing.id !== void 0 && existing.id !== null) {
|
|
1098
|
+
cachedRowId = existing.id;
|
|
1099
|
+
await dataSource.update(resource, existing.id, { value: items });
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
try {
|
|
1103
|
+
const created = await dataSource.create(resource, {
|
|
1104
|
+
user_id: userId,
|
|
1105
|
+
key,
|
|
1106
|
+
value: items
|
|
1107
|
+
});
|
|
1108
|
+
const newId = created?.id;
|
|
1109
|
+
if (newId !== void 0 && newId !== null) cachedRowId = newId;
|
|
1110
|
+
} catch (createError) {
|
|
1111
|
+
const recovered = await findExisting();
|
|
1112
|
+
if (recovered && recovered.id !== void 0 && recovered.id !== null) {
|
|
1113
|
+
cachedRowId = recovered.id;
|
|
1114
|
+
await dataSource.update(resource, recovered.id, { value: items });
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
throw createError;
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
return {
|
|
1121
|
+
async load() {
|
|
1122
|
+
try {
|
|
1123
|
+
const row = await findExisting();
|
|
1124
|
+
if (!row) {
|
|
1125
|
+
cachedRowId = null;
|
|
1126
|
+
return [];
|
|
1127
|
+
}
|
|
1128
|
+
if (row.id !== void 0 && row.id !== null) cachedRowId = row.id;
|
|
1129
|
+
return decodeValue(row.value);
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
onError("load", error);
|
|
1132
|
+
return [];
|
|
1133
|
+
}
|
|
1134
|
+
},
|
|
1135
|
+
async save(items) {
|
|
1136
|
+
const run = saveChain.then(() => upsert(items)).catch((error) => {
|
|
1137
|
+
onError("save", error);
|
|
1138
|
+
});
|
|
1139
|
+
saveChain = run;
|
|
1140
|
+
return run;
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// src/integration.ts
|
|
1146
|
+
var IntegrationManager = class {
|
|
1147
|
+
constructor() {
|
|
1148
|
+
__publicField(this, "integrations", /* @__PURE__ */ new Map());
|
|
1036
1149
|
}
|
|
1037
1150
|
/**
|
|
1038
|
-
*
|
|
1039
|
-
* for pre-delete impact analysis: "Are any views pointing at this
|
|
1040
|
-
* object before I drop it?".
|
|
1151
|
+
* Register a new integration.
|
|
1041
1152
|
*/
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
1045
|
-
if (res.status === 404) return [];
|
|
1046
|
-
if (!res.ok) throw await parseError(res);
|
|
1047
|
-
const data = await res.json();
|
|
1048
|
-
return Array.isArray(data) ? data : data?.items ?? [];
|
|
1153
|
+
register(id, config) {
|
|
1154
|
+
this.integrations.set(id, config);
|
|
1049
1155
|
}
|
|
1050
1156
|
/**
|
|
1051
|
-
*
|
|
1052
|
-
* Idempotent: returns the result even when no overlay row existed.
|
|
1053
|
-
* Pass `state: 'draft'` to discard the pending draft only (keeps the
|
|
1054
|
-
* published overlay intact) — useful for a Studio "Discard draft" button.
|
|
1157
|
+
* Remove an integration.
|
|
1055
1158
|
*/
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
1059
|
-
const headers = { ...this.headers };
|
|
1060
|
-
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
1061
|
-
if (options.actor) headers["X-Actor"] = options.actor;
|
|
1062
|
-
const res = await this.fetchImpl(url, { method: "DELETE", headers });
|
|
1063
|
-
if (!res.ok) throw await parseError(res);
|
|
1064
|
-
return await res.json();
|
|
1159
|
+
unregister(id) {
|
|
1160
|
+
this.integrations.delete(id);
|
|
1065
1161
|
}
|
|
1066
1162
|
/**
|
|
1067
|
-
*
|
|
1068
|
-
* (POST `/meta/:type/:name/publish`). Returns
|
|
1069
|
-
* `{ success, version, seq, message }`. Throws a `404 no_draft` if
|
|
1070
|
-
* nothing is pending and `409 metadata_conflict` if the published
|
|
1071
|
-
* overlay moved while the draft was sitting.
|
|
1163
|
+
* Get all registered integrations.
|
|
1072
1164
|
*/
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
const headers = {
|
|
1076
|
-
...this.headers,
|
|
1077
|
-
"Content-Type": "application/json"
|
|
1078
|
-
};
|
|
1079
|
-
if (options.actor) headers["X-Actor"] = options.actor;
|
|
1080
|
-
const res = await this.fetchImpl(url, {
|
|
1081
|
-
method: "POST",
|
|
1082
|
-
headers,
|
|
1083
|
-
body: JSON.stringify(options.message ? { message: options.message } : {})
|
|
1084
|
-
});
|
|
1085
|
-
if (!res.ok) throw await parseError(res);
|
|
1086
|
-
return await res.json();
|
|
1165
|
+
getAll() {
|
|
1166
|
+
return new Map(this.integrations);
|
|
1087
1167
|
}
|
|
1088
1168
|
/**
|
|
1089
|
-
*
|
|
1090
|
-
* The server reads the history row at `toVersion`, writes its body back
|
|
1091
|
-
* as the active overlay with `operation_type='revert'`. Returns
|
|
1092
|
-
* `{ success, version, seq, restoredFromVersion, message }`. Throws
|
|
1093
|
-
* `404 version_not_found` for unknown versions and `409 version_not_restorable`
|
|
1094
|
-
* when the target version is a delete tombstone.
|
|
1169
|
+
* Get integrations that match a specific event.
|
|
1095
1170
|
*/
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
"Content-Type": "application/json"
|
|
1101
|
-
};
|
|
1102
|
-
if (options.actor) headers["X-Actor"] = options.actor;
|
|
1103
|
-
const res = await this.fetchImpl(url, {
|
|
1104
|
-
method: "POST",
|
|
1105
|
-
headers,
|
|
1106
|
-
body: JSON.stringify({
|
|
1107
|
-
toVersion,
|
|
1108
|
-
...options.message ? { message: options.message } : {}
|
|
1109
|
-
})
|
|
1110
|
-
});
|
|
1111
|
-
if (!res.ok) throw await parseError(res);
|
|
1112
|
-
return await res.json();
|
|
1171
|
+
getForEvent(event) {
|
|
1172
|
+
return Array.from(this.integrations.values()).filter(
|
|
1173
|
+
(integration) => integration.enabled && integration.triggers?.some((t) => t.event === event)
|
|
1174
|
+
);
|
|
1113
1175
|
}
|
|
1114
1176
|
/**
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
* `{ added, removed, changed }` where each entry carries a `path` and
|
|
1118
|
-
* the relevant value(s). Useful for "what changed in this draft?" and
|
|
1119
|
-
* pre-rollback previews.
|
|
1177
|
+
* Dispatch an event to all matching integrations.
|
|
1178
|
+
* Returns results for each integration.
|
|
1120
1179
|
*/
|
|
1121
|
-
async
|
|
1122
|
-
const
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
return
|
|
1134
|
-
}
|
|
1135
|
-
/** Fetch the durable history (change log) for one metadata item. */
|
|
1136
|
-
async history(type, name, options = {}) {
|
|
1137
|
-
const params = new URLSearchParams();
|
|
1138
|
-
if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
|
|
1139
|
-
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
1140
|
-
const qs = params.toString();
|
|
1141
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
|
|
1142
|
-
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
1143
|
-
if (!res.ok) throw await parseError(res);
|
|
1144
|
-
return await res.json();
|
|
1180
|
+
async dispatch(event, payload) {
|
|
1181
|
+
const matching = this.getForEvent(event);
|
|
1182
|
+
const results = [];
|
|
1183
|
+
for (const [id, integration] of this.integrations) {
|
|
1184
|
+
if (!matching.includes(integration)) continue;
|
|
1185
|
+
try {
|
|
1186
|
+
await this.send(integration, payload);
|
|
1187
|
+
results.push({ id, success: true });
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
results.push({ id, success: false, error: err.message });
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
return results;
|
|
1145
1193
|
}
|
|
1146
1194
|
/**
|
|
1147
|
-
*
|
|
1148
|
-
* (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
|
|
1149
|
-
* delete/reset attempt — allowed, denied, or forced — so the
|
|
1150
|
-
* Studio "审计日志 / Audit log" tab can render who tried what
|
|
1151
|
-
* and whether a lock blocked it.
|
|
1195
|
+
* Send payload to a specific integration.
|
|
1152
1196
|
*/
|
|
1153
|
-
async
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1197
|
+
async send(integration, payload) {
|
|
1198
|
+
switch (integration.provider) {
|
|
1199
|
+
case "webhook": {
|
|
1200
|
+
const cfg = integration.config;
|
|
1201
|
+
const url = cfg.url;
|
|
1202
|
+
try {
|
|
1203
|
+
const parsed = new URL(url);
|
|
1204
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1205
|
+
throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
|
|
1206
|
+
}
|
|
1207
|
+
} catch (e) {
|
|
1208
|
+
if (e instanceof TypeError) {
|
|
1209
|
+
throw new Error(`Invalid webhook URL: ${url}`);
|
|
1210
|
+
}
|
|
1211
|
+
throw e;
|
|
1212
|
+
}
|
|
1213
|
+
await fetch(url, {
|
|
1214
|
+
method: cfg.method,
|
|
1215
|
+
headers: {
|
|
1216
|
+
"Content-Type": "application/json",
|
|
1217
|
+
...cfg.headers
|
|
1218
|
+
},
|
|
1219
|
+
body: JSON.stringify(payload)
|
|
1220
|
+
});
|
|
1221
|
+
break;
|
|
1222
|
+
}
|
|
1223
|
+
case "slack": {
|
|
1224
|
+
const cfg = integration.config;
|
|
1225
|
+
const url = cfg.webhookUrl;
|
|
1226
|
+
try {
|
|
1227
|
+
const parsed = new URL(url);
|
|
1228
|
+
if (parsed.protocol !== "https:") {
|
|
1229
|
+
throw new Error(`Invalid Slack webhook URL protocol: ${parsed.protocol}`);
|
|
1230
|
+
}
|
|
1231
|
+
} catch (e) {
|
|
1232
|
+
if (e instanceof TypeError) {
|
|
1233
|
+
throw new Error(`Invalid Slack webhook URL: ${url}`);
|
|
1234
|
+
}
|
|
1235
|
+
throw e;
|
|
1236
|
+
}
|
|
1237
|
+
await fetch(url, {
|
|
1238
|
+
method: "POST",
|
|
1239
|
+
headers: { "Content-Type": "application/json" },
|
|
1240
|
+
body: JSON.stringify({
|
|
1241
|
+
channel: cfg.channel,
|
|
1242
|
+
username: cfg.username,
|
|
1243
|
+
icon_emoji: cfg.iconEmoji,
|
|
1244
|
+
text: JSON.stringify(payload)
|
|
1245
|
+
})
|
|
1246
|
+
});
|
|
1247
|
+
break;
|
|
1248
|
+
}
|
|
1249
|
+
// Email and other providers would require server-side implementation
|
|
1250
|
+
default:
|
|
1251
|
+
break;
|
|
1252
|
+
}
|
|
1161
1253
|
}
|
|
1162
1254
|
};
|
|
1163
1255
|
|
|
@@ -1348,38 +1440,107 @@ var FILTER_OPERATOR_ALIASES = {
|
|
|
1348
1440
|
is_null: "isnull",
|
|
1349
1441
|
isnull: "isnull",
|
|
1350
1442
|
is_not_null: "isnotnull",
|
|
1351
|
-
isnotnull: "isnotnull"
|
|
1443
|
+
isnotnull: "isnotnull",
|
|
1444
|
+
// Date comparisons. `before`/`after` are CANONICAL members of the spec's
|
|
1445
|
+
// `VIEW_FILTER_OPERATORS` (ui/view.zod.ts), so a stored view legitimately
|
|
1446
|
+
// carries them — but they are absent from `VALID_AST_OPERATORS`
|
|
1447
|
+
// (data/filter.zod.ts), which gates `isFilterAST()`. Without these two
|
|
1448
|
+
// entries they reached the wire unchanged, the server's AST gate rejected
|
|
1449
|
+
// the shape, and driver-sql skipped the filter ENTIRELY — an unfiltered
|
|
1450
|
+
// result set with no error anywhere. objectstack#3948.
|
|
1451
|
+
before: "<",
|
|
1452
|
+
after: ">"
|
|
1352
1453
|
};
|
|
1353
1454
|
function normalizeFilterOperator(op) {
|
|
1354
1455
|
if (typeof op !== "string") return null;
|
|
1355
1456
|
const lower = op.toLowerCase();
|
|
1356
1457
|
return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
|
|
1357
1458
|
}
|
|
1459
|
+
var MalformedFilterError = class extends Error {
|
|
1460
|
+
constructor(entry, index) {
|
|
1461
|
+
const shown = JSON.stringify(entry) ?? String(entry);
|
|
1462
|
+
super(
|
|
1463
|
+
`Filter entry ${index} is not a usable filter rule (${shown}). Expected { field, operator, value } with a non-empty field.`
|
|
1464
|
+
);
|
|
1465
|
+
__publicField(this, "code", "INVALID_FILTER");
|
|
1466
|
+
__publicField(this, "httpStatus", 400);
|
|
1467
|
+
__publicField(this, "entry");
|
|
1468
|
+
__publicField(this, "index");
|
|
1469
|
+
this.name = "MalformedFilterError";
|
|
1470
|
+
this.entry = entry;
|
|
1471
|
+
this.index = index;
|
|
1472
|
+
}
|
|
1473
|
+
};
|
|
1474
|
+
function isMalformedFilterError(error) {
|
|
1475
|
+
if (!error || typeof error !== "object") return false;
|
|
1476
|
+
const e = error;
|
|
1477
|
+
return e.code === "INVALID_FILTER" || e.name === "MalformedFilterError";
|
|
1478
|
+
}
|
|
1358
1479
|
function objectFilterEntryToAST(entry) {
|
|
1359
1480
|
if (!entry || typeof entry !== "object") return null;
|
|
1360
|
-
const field = entry.field
|
|
1481
|
+
const field = entry.field;
|
|
1361
1482
|
const rawOp = entry.operator ?? entry.op ?? "=";
|
|
1362
1483
|
const op = normalizeFilterOperator(rawOp);
|
|
1363
1484
|
if (!field || !op) return null;
|
|
1364
1485
|
return [String(field), op, entry.value];
|
|
1365
1486
|
}
|
|
1487
|
+
function isObjectFilterEntryForm(filter) {
|
|
1488
|
+
const first = filter[0];
|
|
1489
|
+
return filter.length > 0 && typeof first === "object" && first !== null && !Array.isArray(first) && first.field !== void 0;
|
|
1490
|
+
}
|
|
1491
|
+
function objectFilterEntriesToAST(entries) {
|
|
1492
|
+
const nodes = entries.map((entry, i) => {
|
|
1493
|
+
if (Array.isArray(entry)) return translateFilterChild(entry);
|
|
1494
|
+
const tuple = objectFilterEntryToAST(entry);
|
|
1495
|
+
if (!tuple) throw new MalformedFilterError(entry, i);
|
|
1496
|
+
return tuple;
|
|
1497
|
+
});
|
|
1498
|
+
return nodes.length === 1 ? nodes[0] : ["and", ...nodes];
|
|
1499
|
+
}
|
|
1500
|
+
var LOGICAL_AST_HEADS = /* @__PURE__ */ new Set(["and", "or"]);
|
|
1501
|
+
function translateFilterArray(filter) {
|
|
1502
|
+
if (isObjectFilterEntryForm(filter)) return objectFilterEntriesToAST(filter);
|
|
1503
|
+
const head = filter[0];
|
|
1504
|
+
if (typeof head === "string" && LOGICAL_AST_HEADS.has(head.toLowerCase())) {
|
|
1505
|
+
return [head, ...filter.slice(1).map(translateFilterChild)];
|
|
1506
|
+
}
|
|
1507
|
+
if (filter.every((child) => Array.isArray(child))) return filter.map(translateFilterChild);
|
|
1508
|
+
return filter;
|
|
1509
|
+
}
|
|
1510
|
+
function translateFilterChild(child) {
|
|
1511
|
+
if (Array.isArray(child)) return child.length > 0 ? translateFilterArray(child) : child;
|
|
1512
|
+
if (child && typeof child === "object" && child.field !== void 0) {
|
|
1513
|
+
const tuple = objectFilterEntryToAST(child);
|
|
1514
|
+
if (tuple) return tuple;
|
|
1515
|
+
}
|
|
1516
|
+
return child;
|
|
1517
|
+
}
|
|
1366
1518
|
function translateFilterToAST(filter) {
|
|
1367
1519
|
if (filter === void 0 || filter === null) return void 0;
|
|
1368
1520
|
if (Array.isArray(filter)) {
|
|
1369
1521
|
if (filter.length === 0) return void 0;
|
|
1370
|
-
|
|
1371
|
-
const isObjectForm = filter.length > 0 && typeof first === "object" && first !== null && !Array.isArray(first) && first.field !== void 0;
|
|
1372
|
-
if (isObjectForm) {
|
|
1373
|
-
const tuples = filter.map((entry) => objectFilterEntryToAST(entry)).filter((t) => t !== null);
|
|
1374
|
-
if (tuples.length === 0) return void 0;
|
|
1375
|
-
if (tuples.length === 1) return tuples[0];
|
|
1376
|
-
return ["and", ...tuples];
|
|
1377
|
-
}
|
|
1378
|
-
return filter;
|
|
1522
|
+
return translateFilterArray(filter);
|
|
1379
1523
|
}
|
|
1380
1524
|
if (typeof filter === "object") {
|
|
1381
1525
|
if (Object.keys(filter).length === 0) return void 0;
|
|
1382
|
-
return filter;
|
|
1526
|
+
return convertFiltersToAST(filter);
|
|
1527
|
+
}
|
|
1528
|
+
return void 0;
|
|
1529
|
+
}
|
|
1530
|
+
function serializeOrderBy(orderby) {
|
|
1531
|
+
if (orderby === void 0 || orderby === null) return void 0;
|
|
1532
|
+
if (typeof orderby === "string") {
|
|
1533
|
+
const trimmed = orderby.trim();
|
|
1534
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
1535
|
+
}
|
|
1536
|
+
const shorthand = (field, order) => String(order).toLowerCase() === "desc" ? `-${field}` : field;
|
|
1537
|
+
if (Array.isArray(orderby)) {
|
|
1538
|
+
const parts = orderby.map((item) => typeof item === "string" ? item.trim() : shorthand(item.field, item.order)).filter((s) => s.length > 0);
|
|
1539
|
+
return parts.length > 0 ? parts.join(",") : void 0;
|
|
1540
|
+
}
|
|
1541
|
+
if (typeof orderby === "object") {
|
|
1542
|
+
const parts = Object.entries(orderby).map(([field, order]) => shorthand(field, order));
|
|
1543
|
+
return parts.length > 0 ? parts.join(",") : void 0;
|
|
1383
1544
|
}
|
|
1384
1545
|
return void 0;
|
|
1385
1546
|
}
|
|
@@ -1398,6 +1559,16 @@ async function getSharedDiscovery(baseUrl, fetcher) {
|
|
|
1398
1559
|
function clearSharedDiscoveryCache() {
|
|
1399
1560
|
discoveryCache.clear();
|
|
1400
1561
|
}
|
|
1562
|
+
function readTransactionalBatchCapability(discovery) {
|
|
1563
|
+
const caps = discovery?.capabilities;
|
|
1564
|
+
if (!caps || typeof caps !== "object") return void 0;
|
|
1565
|
+
const value = caps.transactionalBatch;
|
|
1566
|
+
if (typeof value === "boolean") return value;
|
|
1567
|
+
if (value && typeof value === "object" && typeof value.enabled === "boolean") {
|
|
1568
|
+
return value.enabled;
|
|
1569
|
+
}
|
|
1570
|
+
return void 0;
|
|
1571
|
+
}
|
|
1401
1572
|
function is404Error(error) {
|
|
1402
1573
|
if (!error || typeof error !== "object") return false;
|
|
1403
1574
|
const err = error;
|
|
@@ -1405,7 +1576,46 @@ function is404Error(error) {
|
|
|
1405
1576
|
return true;
|
|
1406
1577
|
}
|
|
1407
1578
|
const code = typeof err.code === "string" ? err.code : "";
|
|
1408
|
-
return code
|
|
1579
|
+
return errorCodeIsAnyOf({ code }, ["OBJECT_NOT_FOUND", "RECORD_NOT_FOUND"]);
|
|
1580
|
+
}
|
|
1581
|
+
var AnalyticsNotInstalledError = class extends Error {
|
|
1582
|
+
constructor(surface, detail) {
|
|
1583
|
+
super(
|
|
1584
|
+
`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})` : "")
|
|
1585
|
+
);
|
|
1586
|
+
__publicField(this, "code", "ANALYTICS_NOT_INSTALLED");
|
|
1587
|
+
/** The surface that was unavailable, for the message a host renders. */
|
|
1588
|
+
__publicField(this, "surface");
|
|
1589
|
+
this.name = "AnalyticsNotInstalledError";
|
|
1590
|
+
this.surface = surface;
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
function isAnalyticsNotInstalledError(error) {
|
|
1594
|
+
if (!error || typeof error !== "object") return false;
|
|
1595
|
+
return error.code === "ANALYTICS_NOT_INSTALLED";
|
|
1596
|
+
}
|
|
1597
|
+
var AnalyticsQueryRejectedError = class extends Error {
|
|
1598
|
+
constructor(detail, serverCode) {
|
|
1599
|
+
super(
|
|
1600
|
+
`Analytics rejected the query: ${detail ?? "the request body does not match the AnalyticsQuery contract"}`
|
|
1601
|
+
);
|
|
1602
|
+
__publicField(this, "code", "ANALYTICS_QUERY_REJECTED");
|
|
1603
|
+
/** The server's own error code (e.g. `VALIDATION_FAILED`), when it sent one. */
|
|
1604
|
+
__publicField(this, "serverCode");
|
|
1605
|
+
this.name = "AnalyticsQueryRejectedError";
|
|
1606
|
+
this.serverCode = serverCode;
|
|
1607
|
+
}
|
|
1608
|
+
};
|
|
1609
|
+
function classifyAnalyticsFailure(error) {
|
|
1610
|
+
const err = error ?? {};
|
|
1611
|
+
const code = typeof err.code === "string" ? err.code : void 0;
|
|
1612
|
+
const message = typeof err.message === "string" ? err.message : void 0;
|
|
1613
|
+
const status = typeof err.httpStatus === "number" ? err.httpStatus : typeof err.status === "number" ? err.status : typeof err.statusCode === "number" ? err.statusCode : void 0;
|
|
1614
|
+
if (code === "VALIDATION_FAILED" || status === 400) return { kind: "rejected", code, message };
|
|
1615
|
+
if (status === 404 || status === 501 || code === "NOT_IMPLEMENTED" || code === "ROUTE_NOT_FOUND") {
|
|
1616
|
+
return { kind: "not-installed", code, message };
|
|
1617
|
+
}
|
|
1618
|
+
return { kind: "unknown", code, message };
|
|
1409
1619
|
}
|
|
1410
1620
|
var ConcurrentUpdateError = class extends Error {
|
|
1411
1621
|
constructor(opts) {
|
|
@@ -1427,9 +1637,25 @@ function isConcurrentUpdateError(error) {
|
|
|
1427
1637
|
function normaliseClientError(error) {
|
|
1428
1638
|
if (!error || typeof error !== "object") return error;
|
|
1429
1639
|
const e = error;
|
|
1640
|
+
const details = e.details ?? {};
|
|
1641
|
+
if (e.code === "VALIDATION_FAILED" || e.name === "ValidationError") {
|
|
1642
|
+
const rawFields = Array.isArray(details.fields) ? details.fields : Array.isArray(e.fields) ? e.fields : [];
|
|
1643
|
+
const validationErrors = rawFields.map((f) => {
|
|
1644
|
+
const rec = f ?? {};
|
|
1645
|
+
const field = typeof rec.field === "string" ? rec.field : void 0;
|
|
1646
|
+
if (!field) return null;
|
|
1647
|
+
const message = typeof rec.message === "string" && rec.message.trim() ? rec.message : typeof rec.code === "string" ? rec.code : "";
|
|
1648
|
+
return { field, message };
|
|
1649
|
+
}).filter((x) => x !== null);
|
|
1650
|
+
return new ValidationError(
|
|
1651
|
+
typeof e.message === "string" ? e.message : "Validation failed",
|
|
1652
|
+
validationErrors[0]?.field,
|
|
1653
|
+
validationErrors,
|
|
1654
|
+
{ fields: rawFields }
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1430
1657
|
if (e.code !== "CONCURRENT_UPDATE" && e.httpStatus !== 409) return error;
|
|
1431
1658
|
if (e.code !== "CONCURRENT_UPDATE") return error;
|
|
1432
|
-
const details = e.details ?? {};
|
|
1433
1659
|
return new ConcurrentUpdateError({
|
|
1434
1660
|
currentVersion: typeof details.currentVersion === "string" ? details.currentVersion : null,
|
|
1435
1661
|
currentRecord: details.currentRecord ?? null,
|
|
@@ -1443,7 +1669,7 @@ function createQuietHttpLogger() {
|
|
|
1443
1669
|
const errBody = meta.error;
|
|
1444
1670
|
if (errBody && typeof errBody === "object") {
|
|
1445
1671
|
const code = errBody.code;
|
|
1446
|
-
if (code
|
|
1672
|
+
if (errorCodeIsAnyOf({ code }, ["OBJECT_NOT_FOUND", "RECORD_NOT_FOUND"])) return true;
|
|
1447
1673
|
}
|
|
1448
1674
|
return false;
|
|
1449
1675
|
};
|
|
@@ -1487,6 +1713,8 @@ var ObjectStackAdapter = class {
|
|
|
1487
1713
|
__publicField(this, "reconnectAttempts", 0);
|
|
1488
1714
|
__publicField(this, "baseUrl");
|
|
1489
1715
|
__publicField(this, "token");
|
|
1716
|
+
/** One "analytics capability is missing" console line per adapter, not per widget. */
|
|
1717
|
+
__publicField(this, "analyticsCapabilityWarned", false);
|
|
1490
1718
|
__publicField(this, "fetchImpl");
|
|
1491
1719
|
// In-flight find() requests keyed by resource + serialized params.
|
|
1492
1720
|
// Coalesces concurrent identical reads (e.g. React StrictMode double-mount,
|
|
@@ -1498,11 +1726,30 @@ var ObjectStackAdapter = class {
|
|
|
1498
1726
|
// so optional collections like sys_presence don't hammer the server with
|
|
1499
1727
|
// failing requests on every record open / panel render.
|
|
1500
1728
|
__publicField(this, "missingResources", /* @__PURE__ */ new Set());
|
|
1729
|
+
// Set once the server has told us it can't do a cross-object transactional
|
|
1730
|
+
// batch (the client SDK's data.batchTransaction threw HTTP 404/405/501). After
|
|
1731
|
+
// that, batchTransaction skips the SDK call and serves every call via the
|
|
1732
|
+
// client-side emulation — the non-atomic fallback lives HERE, isolated to the
|
|
1733
|
+
// one adapter that has to cope with a backend lacking server atomicity (#2679).
|
|
1734
|
+
__publicField(this, "batchUnsupported", false);
|
|
1735
|
+
// The server's declared cross-object atomic-batch capability, read from
|
|
1736
|
+
// discovery at connect() (framework #3298 / objectui #2693). `true` → the
|
|
1737
|
+
// backend GUARANTEES an atomic `/batch`, so batchTransaction trusts it and
|
|
1738
|
+
// never degrades to the non-atomic emulation (any failure surfaces as a real
|
|
1739
|
+
// error). `false` or `undefined` (capability absent → backend predates #3298)
|
|
1740
|
+
// → keep the legacy runtime-probe + emulation fallback so a save is still
|
|
1741
|
+
// possible; dropping it there would turn "saves, less safe" into "no save
|
|
1742
|
+
// path" on older backends (#2679 compatibility constraint).
|
|
1743
|
+
__publicField(this, "atomicBatchCapability");
|
|
1501
1744
|
// Subscribers registered via onMutation(). Emitted after each successful
|
|
1502
1745
|
// create/update/delete so data-bound views (ListView, ObjectView, kanban,
|
|
1503
1746
|
// calendar) auto-refresh — the interface ListView relies on to reflect
|
|
1504
1747
|
// inline-edit "Save All" writes without a manual reload.
|
|
1505
1748
|
__publicField(this, "mutationListeners", /* @__PURE__ */ new Set());
|
|
1749
|
+
// Subscribers registered via onWriteWarning(). Emitted after a create/update
|
|
1750
|
+
// whose response carried `droppedFields` (framework #3431/#3455) so the app
|
|
1751
|
+
// shell can surface a toast instead of the strip passing silently.
|
|
1752
|
+
__publicField(this, "writeWarningListeners", /* @__PURE__ */ new Set());
|
|
1506
1753
|
this.client = new ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
|
|
1507
1754
|
this.metadataCache = new MetadataCache(config.cache);
|
|
1508
1755
|
this.autoReconnect = config.autoReconnect ?? true;
|
|
@@ -1536,6 +1783,7 @@ var ObjectStackAdapter = class {
|
|
|
1536
1783
|
return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
|
|
1537
1784
|
});
|
|
1538
1785
|
this.client.discoveryInfo = data;
|
|
1786
|
+
this.atomicBatchCapability = readTransactionalBatchCapability(data);
|
|
1539
1787
|
this.connected = true;
|
|
1540
1788
|
this.reconnectAttempts = 0;
|
|
1541
1789
|
this.setConnectionState("connected");
|
|
@@ -1673,6 +1921,77 @@ var ObjectStackAdapter = class {
|
|
|
1673
1921
|
promise.then(cleanup, cleanup);
|
|
1674
1922
|
return promise;
|
|
1675
1923
|
}
|
|
1924
|
+
/**
|
|
1925
|
+
* Full-text search across every searchable object in a single round-trip.
|
|
1926
|
+
*
|
|
1927
|
+
* Hits `GET /api/v1/search?q=`, the platform's global search endpoint served
|
|
1928
|
+
* by the registered search service (the pinyin full-text plugin) and backed
|
|
1929
|
+
* by `metadata-protocol`'s `searchAll`. Unlike `find(resource, { $search })`
|
|
1930
|
+
* — a per-object metadata-driven search (ADR-0061) — this consults the search
|
|
1931
|
+
* index and ranks hits across objects, so it surfaces records the per-object
|
|
1932
|
+
* fanout misses. Global affordances (⌘K command palette, search page) prefer
|
|
1933
|
+
* this path (framework #3371).
|
|
1934
|
+
*
|
|
1935
|
+
* Returns `{ query, hits }`. A backend without the search plugin installed
|
|
1936
|
+
* answers `404`; we treat that as "no global search here" and return an empty
|
|
1937
|
+
* hit set so callers can fall back to a per-object fanout rather than surface
|
|
1938
|
+
* an error.
|
|
1939
|
+
*/
|
|
1940
|
+
async searchAll(query, options) {
|
|
1941
|
+
const trimmed = (query ?? "").trim();
|
|
1942
|
+
if (trimmed === "") return { query: "", hits: [] };
|
|
1943
|
+
await this.connect();
|
|
1944
|
+
const queryParams = new URLSearchParams();
|
|
1945
|
+
queryParams.set("q", trimmed);
|
|
1946
|
+
if (options?.limit != null && options.limit > 0) {
|
|
1947
|
+
queryParams.set("limit", String(options.limit));
|
|
1948
|
+
}
|
|
1949
|
+
if (options?.objects && options.objects.length > 0) {
|
|
1950
|
+
queryParams.set("objects", options.objects.join(","));
|
|
1951
|
+
}
|
|
1952
|
+
const baseUrl = (this.baseUrl || "").replace(/\/$/, "");
|
|
1953
|
+
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
1954
|
+
const searchPath = hasApiVersionSuffix ? "/search" : "/api/v1/search";
|
|
1955
|
+
const url = `${baseUrl}${searchPath}?${queryParams.toString()}`;
|
|
1956
|
+
const headers = {
|
|
1957
|
+
"Content-Type": "application/json"
|
|
1958
|
+
};
|
|
1959
|
+
if (this.token) {
|
|
1960
|
+
headers["Authorization"] = `Bearer ${this.token}`;
|
|
1961
|
+
}
|
|
1962
|
+
const res = await this.fetchImpl(url, { method: "GET", headers });
|
|
1963
|
+
if (!res.ok) {
|
|
1964
|
+
if (res.status === 404) return { query: trimmed, hits: [] };
|
|
1965
|
+
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
|
|
1966
|
+
const err = new Error(
|
|
1967
|
+
errorBody?.error?.message || errorBody?.message || res.statusText
|
|
1968
|
+
);
|
|
1969
|
+
err.status = res.status;
|
|
1970
|
+
throw err;
|
|
1971
|
+
}
|
|
1972
|
+
const body = await res.json();
|
|
1973
|
+
const payload = body && typeof body === "object" && typeof body.success === "boolean" && "data" in body ? body.data : body;
|
|
1974
|
+
const rawHits = Array.isArray(payload?.hits) ? payload.hits : Array.isArray(payload) ? payload : [];
|
|
1975
|
+
const hits = [];
|
|
1976
|
+
for (const entry of rawHits) {
|
|
1977
|
+
if (!entry || typeof entry !== "object") continue;
|
|
1978
|
+
const h = entry;
|
|
1979
|
+
const object = h.object ?? h.objectName ?? h.object_name;
|
|
1980
|
+
const id = h.id ?? h.record?.id ?? h.record?._id;
|
|
1981
|
+
if (typeof object !== "string" || id == null) continue;
|
|
1982
|
+
hits.push({
|
|
1983
|
+
object,
|
|
1984
|
+
id: String(id),
|
|
1985
|
+
title: typeof h.title === "string" ? h.title : void 0,
|
|
1986
|
+
snippet: typeof h.snippet === "string" ? h.snippet : void 0,
|
|
1987
|
+
record: h.record && typeof h.record === "object" ? h.record : void 0
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
return {
|
|
1991
|
+
query: typeof payload?.query === "string" ? payload.query : trimmed,
|
|
1992
|
+
hits
|
|
1993
|
+
};
|
|
1994
|
+
}
|
|
1676
1995
|
/**
|
|
1677
1996
|
* Find a single record by ID.
|
|
1678
1997
|
*/
|
|
@@ -1736,11 +2055,84 @@ var ObjectStackAdapter = class {
|
|
|
1736
2055
|
this.mutationListeners.delete(callback);
|
|
1737
2056
|
};
|
|
1738
2057
|
}
|
|
2058
|
+
/**
|
|
2059
|
+
* Notify all write-warning subscribers. Isolated like {@link emitMutation}: a
|
|
2060
|
+
* throwing listener must not break the write or starve the others.
|
|
2061
|
+
*/
|
|
2062
|
+
emitWriteWarning(event) {
|
|
2063
|
+
for (const listener of this.writeWarningListeners) {
|
|
2064
|
+
try {
|
|
2065
|
+
listener(event);
|
|
2066
|
+
} catch (err) {
|
|
2067
|
+
console.warn("ObjectStackAdapter: write-warning listener error", err);
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* Read `droppedFields` off a create/update response (framework #3431/#3455)
|
|
2073
|
+
* and, when present, notify write-warning subscribers. Tolerant of a client
|
|
2074
|
+
* whose response type predates `droppedFields`: the field is read structurally
|
|
2075
|
+
* and validated, so an older client (or a backend that never drops) is a no-op.
|
|
2076
|
+
*/
|
|
2077
|
+
notifyDroppedFields(operation, resource, result, id) {
|
|
2078
|
+
const dropped = result?.droppedFields;
|
|
2079
|
+
if (!Array.isArray(dropped) || dropped.length === 0) return;
|
|
2080
|
+
const droppedFields = dropped.filter(
|
|
2081
|
+
(e) => !!e && typeof e === "object" && Array.isArray(e.fields) && e.fields.length > 0
|
|
2082
|
+
);
|
|
2083
|
+
if (droppedFields.length === 0) return;
|
|
2084
|
+
this.emitWriteWarning({ operation, resource, ...id !== void 0 ? { id } : {}, droppedFields });
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* Same, for the cross-object transactional batch (framework #3794). Its
|
|
2088
|
+
* response hangs the events off a top-level `droppedFields` list, each tagged
|
|
2089
|
+
* with the `index` of the operation it came from — `results` entries are bare
|
|
2090
|
+
* record echoes with nowhere to hang a per-row list.
|
|
2091
|
+
*
|
|
2092
|
+
* This is the path that matters most for the warning: `batchTransaction` is
|
|
2093
|
+
* how the console's record form saves a master-detail record, so a
|
|
2094
|
+
* `readonlyWhen`-locked field edited in that form was stripped server-side
|
|
2095
|
+
* while the UI reported a plain success. The operation kind is taken from the
|
|
2096
|
+
* originating op so the toast doesn't call an update a create.
|
|
2097
|
+
*/
|
|
2098
|
+
notifyBatchDroppedFields(operations, payload) {
|
|
2099
|
+
const dropped = payload?.droppedFields;
|
|
2100
|
+
if (!Array.isArray(dropped) || dropped.length === 0) return;
|
|
2101
|
+
for (const entry of dropped) {
|
|
2102
|
+
if (!entry || typeof entry !== "object") continue;
|
|
2103
|
+
const e = entry;
|
|
2104
|
+
if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
|
|
2105
|
+
const op = typeof e.index === "number" ? operations[e.index] : void 0;
|
|
2106
|
+
const operation = (op?.action ?? "create") === "create" ? "create" : "update";
|
|
2107
|
+
this.emitWriteWarning({
|
|
2108
|
+
operation,
|
|
2109
|
+
resource: e.object ?? op?.object ?? "",
|
|
2110
|
+
...op?.id !== void 0 && op?.id !== null ? { id: op.id } : {},
|
|
2111
|
+
droppedFields: [{ object: e.object, fields: e.fields, reason: e.reason }]
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* Subscribe to write-warning events (a create/update dropped caller-supplied
|
|
2117
|
+
* fields — #3431/#3455). Returns an unsubscribe function. The app shell uses
|
|
2118
|
+
* this to toast the user; the write itself already succeeded.
|
|
2119
|
+
*/
|
|
2120
|
+
onWriteWarning(callback) {
|
|
2121
|
+
this.writeWarningListeners.add(callback);
|
|
2122
|
+
return () => {
|
|
2123
|
+
this.writeWarningListeners.delete(callback);
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
1739
2126
|
async create(resource, data) {
|
|
1740
2127
|
await this.connect();
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
2128
|
+
try {
|
|
2129
|
+
const result = await this.client.data.create(resource, data);
|
|
2130
|
+
this.emitMutation({ type: "create", resource, record: { ...result.record } });
|
|
2131
|
+
this.notifyDroppedFields("create", resource, result, result.record?.id);
|
|
2132
|
+
return result.record;
|
|
2133
|
+
} catch (err) {
|
|
2134
|
+
throw normaliseClientError(err);
|
|
2135
|
+
}
|
|
1744
2136
|
}
|
|
1745
2137
|
/**
|
|
1746
2138
|
* Update an existing record.
|
|
@@ -1763,6 +2155,7 @@ var ObjectStackAdapter = class {
|
|
|
1763
2155
|
opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
|
|
1764
2156
|
);
|
|
1765
2157
|
this.emitMutation({ type: "update", resource, id, record: { ...result.record } });
|
|
2158
|
+
this.notifyDroppedFields("update", resource, result, id);
|
|
1766
2159
|
return result.record;
|
|
1767
2160
|
} catch (err) {
|
|
1768
2161
|
throw normaliseClientError(err);
|
|
@@ -1885,33 +2278,85 @@ var ObjectStackAdapter = class {
|
|
|
1885
2278
|
* @returns Promise resolving to array of results
|
|
1886
2279
|
*/
|
|
1887
2280
|
/**
|
|
1888
|
-
* Cross-object transactional batch (ObjectStack #1604
|
|
1889
|
-
* in ONE server transaction — commit all or roll back
|
|
1890
|
-
* `{ $ref: <earlier op index> }` resolves to that op's
|
|
1891
|
-
* can reference its parent created earlier in the same
|
|
2281
|
+
* Cross-object transactional batch (ObjectStack #1604 / ADR-0034 item 4).
|
|
2282
|
+
* Runs the operations in ONE server transaction — commit all or roll back
|
|
2283
|
+
* all. A field value of `{ $ref: <earlier op index> }` resolves to that op's
|
|
2284
|
+
* created id, so a child can reference its parent created earlier in the same
|
|
2285
|
+
* batch (master-detail).
|
|
2286
|
+
*
|
|
2287
|
+
* Transport: the published `@objectstack/client` SDK method
|
|
2288
|
+
* `data.batchTransaction` (framework #3271; shipped since client v16, our
|
|
2289
|
+
* dependency floor). Per AGENTS.md §7 data always flows through the client —
|
|
2290
|
+
* never a hand-rolled `fetch('/api/v1/batch')`.
|
|
2291
|
+
*
|
|
2292
|
+
* Fallback decision — declarative capability negotiation (framework #3298 /
|
|
2293
|
+
* objectui #2693). At connect() we read `capabilities.transactionalBatch`
|
|
2294
|
+
* from discovery:
|
|
2295
|
+
* - Declared `true` → the backend GUARANTEES atomicity (declared ===
|
|
2296
|
+
* enforced). We TRUST it: any batch failure — including 404/405/501 —
|
|
2297
|
+
* surfaces as a real error. No non-atomic client-side compensation. This
|
|
2298
|
+
* is the path modern backends take.
|
|
2299
|
+
* - Declared `false`, or ABSENT (backend predates #3298) → we can't rely on
|
|
2300
|
+
* server atomicity, so we keep the legacy behaviour: on 404/405 (no
|
|
2301
|
+
* endpoint) or 501 (runtime without transactions) degrade to the
|
|
2302
|
+
* client-side, NON-atomic {@link emulateBatchTransaction} so a save is
|
|
2303
|
+
* still possible. Removing that here would regress older backends from
|
|
2304
|
+
* "saves, less safe" to "no save path" (#2679 compatibility constraint).
|
|
2305
|
+
* The non-atomic fallback stays isolated to THIS adapter.
|
|
1892
2306
|
*/
|
|
1893
2307
|
async batchTransaction(operations) {
|
|
1894
|
-
|
|
1895
|
-
const
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
})
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
2308
|
+
await this.connect();
|
|
2309
|
+
const guaranteed = this.atomicBatchCapability === true;
|
|
2310
|
+
if (!guaranteed && this.batchUnsupported) {
|
|
2311
|
+
return emulateBatchTransaction(this, operations);
|
|
2312
|
+
}
|
|
2313
|
+
try {
|
|
2314
|
+
const payload = await this.client.data.batchTransaction(operations);
|
|
2315
|
+
this.emitBatchMutations(operations, payload?.results);
|
|
2316
|
+
this.notifyBatchDroppedFields(operations, payload);
|
|
2317
|
+
return payload;
|
|
2318
|
+
} catch (err) {
|
|
2319
|
+
const status = this.errorStatusOf(err);
|
|
2320
|
+
if (!guaranteed && this.batchStatusUnsupported(status)) {
|
|
2321
|
+
return this.fallbackToEmulation(operations, status);
|
|
2322
|
+
}
|
|
2323
|
+
throw err;
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
/** True for statuses that mean "this backend can't do a transactional batch". */
|
|
2327
|
+
batchStatusUnsupported(status) {
|
|
2328
|
+
return status === 404 || status === 405 || status === 501;
|
|
2329
|
+
}
|
|
2330
|
+
/** Best-effort HTTP status extraction from a thrown SDK/client error. */
|
|
2331
|
+
errorStatusOf(err) {
|
|
2332
|
+
if (!err || typeof err !== "object") return void 0;
|
|
2333
|
+
const e = err;
|
|
2334
|
+
const s = e.httpStatus ?? e.status ?? e.statusCode;
|
|
2335
|
+
return typeof s === "number" ? s : void 0;
|
|
2336
|
+
}
|
|
2337
|
+
/** Mark the endpoint unsupported (warn once) and serve via emulation. */
|
|
2338
|
+
fallbackToEmulation(operations, status) {
|
|
2339
|
+
if (!this.batchUnsupported) {
|
|
2340
|
+
this.batchUnsupported = true;
|
|
2341
|
+
console.warn(
|
|
2342
|
+
`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.`
|
|
1911
2343
|
);
|
|
1912
2344
|
}
|
|
1913
|
-
|
|
1914
|
-
|
|
2345
|
+
return emulateBatchTransaction(this, operations);
|
|
2346
|
+
}
|
|
2347
|
+
/**
|
|
2348
|
+
* Emit one DataSourceMutationEvent per committed operation so the invalidation bus
|
|
2349
|
+
* (#2269) sees writes that went through /batch exactly like single
|
|
2350
|
+
* create/update/delete calls — master-detail ModalForm saves otherwise leave
|
|
2351
|
+
* related lists and count badges stale (#2582). `results` is index-aligned
|
|
2352
|
+
* with `operations`; creates take id/record from the server echo.
|
|
2353
|
+
*
|
|
2354
|
+
* Only called on the server-committed paths. The emulation branch drives the
|
|
2355
|
+
* adapter's own create/update/delete primitives, which already emit — so it
|
|
2356
|
+
* must NOT be routed through here, or events would double-fire.
|
|
2357
|
+
*/
|
|
2358
|
+
emitBatchMutations(operations, rawResults) {
|
|
2359
|
+
const results = Array.isArray(rawResults) ? rawResults : [];
|
|
1915
2360
|
operations.forEach((op, i) => {
|
|
1916
2361
|
const action = op.action ?? "create";
|
|
1917
2362
|
const echo = results[i];
|
|
@@ -1923,7 +2368,6 @@ var ObjectStackAdapter = class {
|
|
|
1923
2368
|
this.emitMutation({ type: "delete", resource: op.object, id: op.id });
|
|
1924
2369
|
}
|
|
1925
2370
|
});
|
|
1926
|
-
return payload;
|
|
1927
2371
|
}
|
|
1928
2372
|
async bulk(resource, operation, data) {
|
|
1929
2373
|
await this.connect();
|
|
@@ -2254,20 +2698,8 @@ var ObjectStackAdapter = class {
|
|
|
2254
2698
|
const selectFields = params.$select.includes("id") ? params.$select : ["id", ...params.$select];
|
|
2255
2699
|
queryParams.set("select", selectFields.join(","));
|
|
2256
2700
|
}
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
const sortStr = params.$orderby.map((item) => {
|
|
2260
|
-
if (typeof item === "string") return item;
|
|
2261
|
-
const field = item.field;
|
|
2262
|
-
const order = item.order || "asc";
|
|
2263
|
-
return order === "desc" ? `-${field}` : field;
|
|
2264
|
-
}).join(",");
|
|
2265
|
-
queryParams.set("sort", sortStr);
|
|
2266
|
-
} else {
|
|
2267
|
-
const sortStr = Object.entries(params.$orderby).map(([field, order]) => order === "desc" ? `-${field}` : field).join(",");
|
|
2268
|
-
queryParams.set("sort", sortStr);
|
|
2269
|
-
}
|
|
2270
|
-
}
|
|
2701
|
+
const sortStr = serializeOrderBy(params.$orderby);
|
|
2702
|
+
if (sortStr) queryParams.set("sort", sortStr);
|
|
2271
2703
|
if (params.$filter !== void 0 && params.$filter !== null) {
|
|
2272
2704
|
const translated = translateFilterToAST(params.$filter);
|
|
2273
2705
|
if (translated !== void 0) {
|
|
@@ -2331,6 +2763,13 @@ var ObjectStackAdapter = class {
|
|
|
2331
2763
|
queryParams.set("filter", JSON.stringify(translated));
|
|
2332
2764
|
}
|
|
2333
2765
|
}
|
|
2766
|
+
const searchTerm = typeof request.search === "string" ? request.search.trim() : "";
|
|
2767
|
+
if (searchTerm) {
|
|
2768
|
+
queryParams.set("search", searchTerm);
|
|
2769
|
+
if (request.searchFields && request.searchFields.length > 0) {
|
|
2770
|
+
queryParams.set("searchFields", request.searchFields.join(","));
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2334
2773
|
const baseUrl = this.baseUrl.replace(/\/$/, "");
|
|
2335
2774
|
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
2336
2775
|
const dataPath = hasApiVersionSuffix ? "/data" : "/api/v1/data";
|
|
@@ -2359,38 +2798,14 @@ var ObjectStackAdapter = class {
|
|
|
2359
2798
|
const isEmpty = Array.isArray(params.$filter) ? params.$filter.length === 0 : typeof params.$filter === "object" && Object.keys(params.$filter).length === 0;
|
|
2360
2799
|
if (!isEmpty) {
|
|
2361
2800
|
if (Array.isArray(params.$filter)) {
|
|
2362
|
-
|
|
2363
|
-
if (isObjectForm) {
|
|
2364
|
-
const tuples = params.$filter.map((entry) => objectFilterEntryToAST(entry)).filter((t) => t !== null);
|
|
2365
|
-
if (tuples.length === 0) {
|
|
2366
|
-
} else if (tuples.length === 1) {
|
|
2367
|
-
options.filters = tuples[0];
|
|
2368
|
-
} else {
|
|
2369
|
-
options.filters = ["and", ...tuples];
|
|
2370
|
-
}
|
|
2371
|
-
} else {
|
|
2372
|
-
options.filters = params.$filter;
|
|
2373
|
-
}
|
|
2801
|
+
options.filters = translateFilterArray(params.$filter);
|
|
2374
2802
|
} else {
|
|
2375
2803
|
options.filters = convertFiltersToAST(params.$filter);
|
|
2376
2804
|
}
|
|
2377
2805
|
}
|
|
2378
2806
|
}
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
options.sort = params.$orderby.map((item) => {
|
|
2382
|
-
if (typeof item === "string") return item;
|
|
2383
|
-
const field = item.field;
|
|
2384
|
-
const order = item.order || "asc";
|
|
2385
|
-
return order === "desc" ? `-${field}` : field;
|
|
2386
|
-
});
|
|
2387
|
-
} else {
|
|
2388
|
-
const sortArray = Object.entries(params.$orderby).map(([field, order]) => {
|
|
2389
|
-
return order === "desc" ? `-${field}` : field;
|
|
2390
|
-
});
|
|
2391
|
-
options.sort = sortArray;
|
|
2392
|
-
}
|
|
2393
|
-
}
|
|
2807
|
+
const sort = serializeOrderBy(params.$orderby);
|
|
2808
|
+
if (sort) options.sort = sort;
|
|
2394
2809
|
if (params.$skip !== void 0) {
|
|
2395
2810
|
options.skip = params.$skip;
|
|
2396
2811
|
}
|
|
@@ -2644,22 +3059,18 @@ var ObjectStackAdapter = class {
|
|
|
2644
3059
|
*/
|
|
2645
3060
|
async updateViewConfig(objectName, viewId, config) {
|
|
2646
3061
|
await this.connect();
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
return result ?? void 0;
|
|
2660
|
-
} catch (err) {
|
|
2661
|
-
throw err;
|
|
2662
|
-
}
|
|
3062
|
+
const merged = { ...config || {}, object: config?.object || objectName, name: viewId };
|
|
3063
|
+
const result = await this.client.meta.saveItem(
|
|
3064
|
+
"view",
|
|
3065
|
+
viewId,
|
|
3066
|
+
merged
|
|
3067
|
+
);
|
|
3068
|
+
const cacheKey = `view:${objectName}:${viewId}`;
|
|
3069
|
+
this.metadataCache.invalidate?.(cacheKey);
|
|
3070
|
+
this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
|
|
3071
|
+
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
3072
|
+
if (result && result.item) return result.item;
|
|
3073
|
+
return result ?? void 0;
|
|
2663
3074
|
}
|
|
2664
3075
|
/**
|
|
2665
3076
|
* List user-created views for a given object via the metadata overlay
|
|
@@ -2671,11 +3082,17 @@ var ObjectStackAdapter = class {
|
|
|
2671
3082
|
* Filters by `data.object === objectName` (or top-level `object`)
|
|
2672
3083
|
* client-side because the metadata index is name-only, not field-typed.
|
|
2673
3084
|
*/
|
|
2674
|
-
async listViews(objectName) {
|
|
3085
|
+
async listViews(objectName, options) {
|
|
2675
3086
|
await this.connect();
|
|
2676
3087
|
try {
|
|
2677
|
-
|
|
2678
|
-
|
|
3088
|
+
let items;
|
|
3089
|
+
if (options?.previewDrafts) {
|
|
3090
|
+
const draftItems = await this.metadataClient().withPreviewDrafts(true).list("view");
|
|
3091
|
+
items = Array.isArray(draftItems) ? draftItems : [];
|
|
3092
|
+
} else {
|
|
3093
|
+
const result = await this.client.meta.getItems("view");
|
|
3094
|
+
items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
|
|
3095
|
+
}
|
|
2679
3096
|
const FORM_FAMILY = /* @__PURE__ */ new Set(["form", "detail"]);
|
|
2680
3097
|
return items.filter((v) => {
|
|
2681
3098
|
if (!v) return false;
|
|
@@ -2686,21 +3103,36 @@ var ObjectStackAdapter = class {
|
|
|
2686
3103
|
return !(viewKind && FORM_FAMILY.has(viewKind));
|
|
2687
3104
|
}).map((v) => {
|
|
2688
3105
|
const spec = v.list ?? v;
|
|
3106
|
+
const isDraft = v._draft === true || spec?._draft === true;
|
|
2689
3107
|
if (spec && spec.config && typeof spec.config === "object") {
|
|
2690
3108
|
return {
|
|
2691
3109
|
...spec.config,
|
|
2692
3110
|
name: spec.name ?? spec.config.name,
|
|
2693
3111
|
label: spec.label ?? spec.config.label,
|
|
2694
|
-
isDefault: !!spec.isDefault
|
|
3112
|
+
isDefault: !!spec.isDefault,
|
|
3113
|
+
...isDraft ? { _draft: true } : {}
|
|
2695
3114
|
};
|
|
2696
3115
|
}
|
|
2697
|
-
return spec;
|
|
3116
|
+
return isDraft ? { ...spec, _draft: true } : spec;
|
|
2698
3117
|
});
|
|
2699
3118
|
} catch (err) {
|
|
2700
3119
|
console.warn("[OBJECTSTACKDataSource] listViews failed:", err);
|
|
2701
3120
|
return [];
|
|
2702
3121
|
}
|
|
2703
3122
|
}
|
|
3123
|
+
/**
|
|
3124
|
+
* Build a {@link MetadataClient} bound to this adapter's server + auth. Used
|
|
3125
|
+
* by draft-aware reads (`listViews({ previewDrafts })`) so the `/meta` route,
|
|
3126
|
+
* `?preview=draft` flag, and environment scoping live in the SDK rather than
|
|
3127
|
+
* being hand-assembled at each call site (#2767 P3).
|
|
3128
|
+
*/
|
|
3129
|
+
metadataClient() {
|
|
3130
|
+
return new MetadataClient({
|
|
3131
|
+
baseUrl: this.baseUrl,
|
|
3132
|
+
fetch: this.fetchImpl,
|
|
3133
|
+
...this.token ? { headers: { Authorization: `Bearer ${this.token}` } } : {}
|
|
3134
|
+
});
|
|
3135
|
+
}
|
|
2704
3136
|
/**
|
|
2705
3137
|
* List registered import `mapping` artifacts targeting a given object
|
|
2706
3138
|
* (framework #2611). Reads the `mapping` metadata kind via the overlay API
|
|
@@ -2745,14 +3177,10 @@ var ObjectStackAdapter = class {
|
|
|
2745
3177
|
object: spec?.object || objectName,
|
|
2746
3178
|
data: spec?.data || { provider: "object", object: objectName }
|
|
2747
3179
|
};
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
return fullSpec;
|
|
2753
|
-
} catch (err) {
|
|
2754
|
-
throw err;
|
|
2755
|
-
}
|
|
3180
|
+
const result = await this.client.meta.saveItem("view", name, fullSpec);
|
|
3181
|
+
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
3182
|
+
if (result && result.item) return result.item;
|
|
3183
|
+
return fullSpec;
|
|
2756
3184
|
}
|
|
2757
3185
|
/**
|
|
2758
3186
|
* Apply a partial update to an existing overlay view. Reads the current
|
|
@@ -2775,15 +3203,11 @@ var ObjectStackAdapter = class {
|
|
|
2775
3203
|
name: viewName,
|
|
2776
3204
|
object: current?.object || current?.data?.object || objectName
|
|
2777
3205
|
};
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
return merged;
|
|
2784
|
-
} catch (err) {
|
|
2785
|
-
throw err;
|
|
2786
|
-
}
|
|
3206
|
+
const result = await this.client.meta.saveItem("view", viewName, merged);
|
|
3207
|
+
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
3208
|
+
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
|
|
3209
|
+
if (result && result.item) return result.item;
|
|
3210
|
+
return merged;
|
|
2787
3211
|
}
|
|
2788
3212
|
/**
|
|
2789
3213
|
* Delete an overlay view (reset to artifact default if one exists, or
|
|
@@ -2792,14 +3216,10 @@ var ObjectStackAdapter = class {
|
|
|
2792
3216
|
*/
|
|
2793
3217
|
async deleteView(objectName, viewName) {
|
|
2794
3218
|
await this.connect();
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
return { deleted: !!(result?.deleted ?? result?.reset ?? true) };
|
|
2800
|
-
} catch (err) {
|
|
2801
|
-
throw err;
|
|
2802
|
-
}
|
|
3219
|
+
const result = await this.client.meta.deleteItem("view", viewName);
|
|
3220
|
+
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
3221
|
+
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
|
|
3222
|
+
return { deleted: !!(result?.deleted ?? result?.reset ?? true) };
|
|
2803
3223
|
}
|
|
2804
3224
|
/**
|
|
2805
3225
|
* Get an application definition by name or ID.
|
|
@@ -2855,19 +3275,15 @@ var ObjectStackAdapter = class {
|
|
|
2855
3275
|
*/
|
|
2856
3276
|
async updateDashboard(dashboardName, schema) {
|
|
2857
3277
|
await this.connect();
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
return result ?? void 0;
|
|
2868
|
-
} catch (err) {
|
|
2869
|
-
throw err;
|
|
2870
|
-
}
|
|
3278
|
+
const result = await this.client.meta.saveItem(
|
|
3279
|
+
"dashboard",
|
|
3280
|
+
dashboardName,
|
|
3281
|
+
schema
|
|
3282
|
+
);
|
|
3283
|
+
this.metadataCache.invalidate?.("dashboards");
|
|
3284
|
+
this.metadataCache.invalidate?.(`dashboard:${dashboardName}`);
|
|
3285
|
+
if (result && result.item) return result.item;
|
|
3286
|
+
return result ?? void 0;
|
|
2871
3287
|
}
|
|
2872
3288
|
/**
|
|
2873
3289
|
* Perform server-side aggregation via the ObjectStack analytics API.
|
|
@@ -2893,6 +3309,7 @@ var ObjectStackAdapter = class {
|
|
|
2893
3309
|
}
|
|
2894
3310
|
try {
|
|
2895
3311
|
const measureName = params.function === "count" ? "count" : `${params.field}_${params.function}`;
|
|
3312
|
+
const valueKey = this.aggregateValueKey(params);
|
|
2896
3313
|
const payload = {
|
|
2897
3314
|
cube: resource,
|
|
2898
3315
|
measures: [measureName],
|
|
@@ -2907,30 +3324,62 @@ var ObjectStackAdapter = class {
|
|
|
2907
3324
|
const measureMissing = rawRows.length > 0 && rawRows.every((row) => {
|
|
2908
3325
|
if (row == null) return true;
|
|
2909
3326
|
if (measureName in row && row[measureName] != null) return false;
|
|
2910
|
-
if (
|
|
3327
|
+
if (valueKey in row && row[valueKey] != null) return false;
|
|
2911
3328
|
return true;
|
|
2912
3329
|
});
|
|
2913
3330
|
if (measureMissing) {
|
|
2914
|
-
|
|
2915
|
-
const records = result.data || [];
|
|
2916
|
-
if (records.length === 0) return [];
|
|
2917
|
-
return this.aggregateClientSide(records, params);
|
|
3331
|
+
return await this.aggregateViaFind(resource, params);
|
|
2918
3332
|
}
|
|
2919
3333
|
return rawRows.map((row) => {
|
|
2920
3334
|
const mapped = { ...row };
|
|
2921
|
-
if (measureName !==
|
|
2922
|
-
mapped[
|
|
3335
|
+
if (measureName !== valueKey && measureName in mapped) {
|
|
3336
|
+
mapped[valueKey] = mapped[measureName];
|
|
2923
3337
|
delete mapped[measureName];
|
|
2924
3338
|
}
|
|
2925
3339
|
return mapped;
|
|
2926
3340
|
});
|
|
2927
|
-
} catch {
|
|
2928
|
-
const
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
3341
|
+
} catch (e) {
|
|
3342
|
+
const failure = classifyAnalyticsFailure(e);
|
|
3343
|
+
if (failure.kind === "rejected") {
|
|
3344
|
+
throw new AnalyticsQueryRejectedError(failure.message, failure.code);
|
|
3345
|
+
}
|
|
3346
|
+
if (failure.kind === "not-installed") {
|
|
3347
|
+
this.warnAnalyticsCapabilityOnce(failure.message);
|
|
3348
|
+
}
|
|
3349
|
+
return await this.aggregateViaFind(resource, params);
|
|
2932
3350
|
}
|
|
2933
3351
|
}
|
|
3352
|
+
/**
|
|
3353
|
+
* Client-side aggregation over a server-scoped `find()` — the fallback used
|
|
3354
|
+
* whenever the analytics endpoint cannot answer (capability absent, network
|
|
3355
|
+
* failure, or a result whose measure came back missing).
|
|
3356
|
+
*
|
|
3357
|
+
* Forwarding `params.filter` is load-bearing: without it the fallback
|
|
3358
|
+
* aggregates the whole table while the caller believes it applied a filter,
|
|
3359
|
+
* which is the "KPI silently sums everything" failure this adapter has
|
|
3360
|
+
* guarded against since the widget filter was threaded through.
|
|
3361
|
+
*/
|
|
3362
|
+
async aggregateViaFind(resource, params) {
|
|
3363
|
+
const result = await this.find(
|
|
3364
|
+
resource,
|
|
3365
|
+
params.filter ? { $filter: params.filter } : void 0
|
|
3366
|
+
);
|
|
3367
|
+
const records = result.data || [];
|
|
3368
|
+
if (records.length === 0) return [];
|
|
3369
|
+
return this.aggregateClientSide(records, params);
|
|
3370
|
+
}
|
|
3371
|
+
/**
|
|
3372
|
+
* Say "the analytics capability isn't installed" ONCE per adapter, not once
|
|
3373
|
+
* per widget: a dashboard fans out one aggregate() per KPI, and N identical
|
|
3374
|
+
* console lines read like N different failures.
|
|
3375
|
+
*/
|
|
3376
|
+
warnAnalyticsCapabilityOnce(detail) {
|
|
3377
|
+
if (this.analyticsCapabilityWarned) return;
|
|
3378
|
+
this.analyticsCapabilityWarned = true;
|
|
3379
|
+
console.warn(
|
|
3380
|
+
"[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}` : "")
|
|
3381
|
+
);
|
|
3382
|
+
}
|
|
2934
3383
|
/**
|
|
2935
3384
|
* Run a semantic-layer `dataset` (ADR-0021) and return chart-ready rows.
|
|
2936
3385
|
*
|
|
@@ -2969,6 +3418,9 @@ var ObjectStackAdapter = class {
|
|
|
2969
3418
|
detail = errBody?.message || errBody?.error || JSON.stringify(errBody);
|
|
2970
3419
|
} catch {
|
|
2971
3420
|
}
|
|
3421
|
+
if (res.status === 501 || res.status === 404) {
|
|
3422
|
+
throw new AnalyticsNotInstalledError("POST /analytics/dataset/query", detail || void 0);
|
|
3423
|
+
}
|
|
2972
3424
|
throw new Error(`Dataset query failed: ${res.status} ${res.statusText}${detail ? ` \u2014 ${detail}` : ""}`);
|
|
2973
3425
|
}
|
|
2974
3426
|
const payload = await res.json();
|
|
@@ -2982,8 +3434,18 @@ var ObjectStackAdapter = class {
|
|
|
2982
3434
|
return { rows, fields, object, dimensionFields, drillRawRows, totals };
|
|
2983
3435
|
}
|
|
2984
3436
|
/** Client-side aggregation fallback */
|
|
3437
|
+
/**
|
|
3438
|
+
* The result column an object-bound `aggregate` projects its value under
|
|
3439
|
+
* (framework#3701, `chartAggregateValueKey` in `@objectstack/spec/ui`): the
|
|
3440
|
+
* raw `field` name — no `sum_`-style decoration, unlike a dataset measure —
|
|
3441
|
+
* or the literal `count` when a count names no field.
|
|
3442
|
+
*/
|
|
3443
|
+
aggregateValueKey(params) {
|
|
3444
|
+
return params.field || params.function || "count";
|
|
3445
|
+
}
|
|
2985
3446
|
aggregateClientSide(records, params) {
|
|
2986
3447
|
const { field, function: aggFn, groupBy } = params;
|
|
3448
|
+
const valueKey = this.aggregateValueKey(params);
|
|
2987
3449
|
const groups = {};
|
|
2988
3450
|
for (const record of records) {
|
|
2989
3451
|
const key = String(record[groupBy] ?? "Unknown");
|
|
@@ -2991,7 +3453,7 @@ var ObjectStackAdapter = class {
|
|
|
2991
3453
|
groups[key].push(record);
|
|
2992
3454
|
}
|
|
2993
3455
|
return Object.entries(groups).map(([key, group]) => {
|
|
2994
|
-
const values = group.map((r) => Number(r[field]) || 0);
|
|
3456
|
+
const values = field ? group.map((r) => Number(r[field]) || 0) : [];
|
|
2995
3457
|
let result;
|
|
2996
3458
|
switch (aggFn) {
|
|
2997
3459
|
case "count":
|
|
@@ -3011,7 +3473,7 @@ var ObjectStackAdapter = class {
|
|
|
3011
3473
|
result = values.reduce((a, b) => a + b, 0);
|
|
3012
3474
|
break;
|
|
3013
3475
|
}
|
|
3014
|
-
return { [groupBy]: key, [
|
|
3476
|
+
return { [groupBy]: key, [valueKey]: result };
|
|
3015
3477
|
});
|
|
3016
3478
|
}
|
|
3017
3479
|
/**
|
|
@@ -3213,12 +3675,16 @@ function createObjectStackAdapter(config) {
|
|
|
3213
3675
|
return new ObjectStackAdapter(config);
|
|
3214
3676
|
}
|
|
3215
3677
|
export {
|
|
3678
|
+
AnalyticsNotInstalledError,
|
|
3679
|
+
AnalyticsQueryRejectedError,
|
|
3216
3680
|
AuthenticationError,
|
|
3217
3681
|
BulkOperationError,
|
|
3218
3682
|
CloudOperations,
|
|
3219
3683
|
ConcurrentUpdateError,
|
|
3220
3684
|
ConnectionError,
|
|
3685
|
+
FILTER_OPERATOR_ALIASES,
|
|
3221
3686
|
IntegrationManager,
|
|
3687
|
+
MalformedFilterError,
|
|
3222
3688
|
MetadataClient,
|
|
3223
3689
|
MetadataNotFoundError,
|
|
3224
3690
|
ObjectStackAdapter,
|
|
@@ -3226,6 +3692,7 @@ export {
|
|
|
3226
3692
|
SecurityManager,
|
|
3227
3693
|
ValidationError,
|
|
3228
3694
|
calculateAutoLayout,
|
|
3695
|
+
classifyAnalyticsFailure,
|
|
3229
3696
|
clearSharedDiscoveryCache,
|
|
3230
3697
|
createDefaultCanvasConfig,
|
|
3231
3698
|
createErrorFromResponse,
|
|
@@ -3234,10 +3701,14 @@ export {
|
|
|
3234
3701
|
generateContractManifest,
|
|
3235
3702
|
getSharedDiscovery,
|
|
3236
3703
|
is404Error,
|
|
3704
|
+
isAnalyticsNotInstalledError,
|
|
3237
3705
|
isConcurrentUpdateError,
|
|
3238
3706
|
isErrorType,
|
|
3707
|
+
isMalformedFilterError,
|
|
3239
3708
|
isObjectStackError,
|
|
3240
3709
|
normaliseClientError,
|
|
3710
|
+
readTransactionalBatchCapability,
|
|
3711
|
+
serializeOrderBy,
|
|
3241
3712
|
snapToGrid,
|
|
3242
3713
|
validatePluginContract
|
|
3243
3714
|
};
|