@object-ui/data-objectstack 15.0.0 → 16.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 +210 -0
- package/README.md +63 -0
- package/dist/index.cjs +1022 -872
- package/dist/index.d.cts +79 -14
- package/dist/index.d.ts +79 -14
- package/dist/index.js +1021 -872
- package/package.json +4 -4
- package/src/exportDownload.test.ts +9 -3
- package/src/index.ts +259 -105
- 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 +267 -2
package/dist/index.cjs
CHANGED
|
@@ -47,6 +47,7 @@ __export(index_exports, {
|
|
|
47
47
|
isErrorType: () => isErrorType,
|
|
48
48
|
isObjectStackError: () => isObjectStackError,
|
|
49
49
|
normaliseClientError: () => normaliseClientError,
|
|
50
|
+
readTransactionalBatchCapability: () => readTransactionalBatchCapability,
|
|
50
51
|
snapToGrid: () => snapToGrid,
|
|
51
52
|
validatePluginContract: () => validatePluginContract
|
|
52
53
|
});
|
|
@@ -119,6 +120,72 @@ function convertFiltersToAST(filter) {
|
|
|
119
120
|
return ["and", ...conditions];
|
|
120
121
|
}
|
|
121
122
|
|
|
123
|
+
// ../core/src/adapters/batchTransaction.ts
|
|
124
|
+
function idOf(rec) {
|
|
125
|
+
if (rec == null) return void 0;
|
|
126
|
+
return rec.id ?? rec._id ?? rec.recordId;
|
|
127
|
+
}
|
|
128
|
+
function resolveRefs(data, results) {
|
|
129
|
+
const out = {};
|
|
130
|
+
for (const [key, value] of Object.entries(data)) {
|
|
131
|
+
if (value && typeof value === "object" && !Array.isArray(value) && typeof value.$ref === "number") {
|
|
132
|
+
const refIndex = value.$ref;
|
|
133
|
+
const id = idOf(results[refIndex]);
|
|
134
|
+
if (id === void 0) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`batchTransaction: field "${key}" references operation ${refIndex}, which has not produced an id (forward or invalid $ref)`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
out[key] = id;
|
|
140
|
+
} else {
|
|
141
|
+
out[key] = value;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
async function emulateBatchTransaction(dataSource, operations) {
|
|
147
|
+
const results = [];
|
|
148
|
+
const created = [];
|
|
149
|
+
try {
|
|
150
|
+
for (const op of operations) {
|
|
151
|
+
const action = op.action ?? "create";
|
|
152
|
+
if (action === "create") {
|
|
153
|
+
const record = await dataSource.create(
|
|
154
|
+
op.object,
|
|
155
|
+
resolveRefs(op.data ?? {}, results)
|
|
156
|
+
);
|
|
157
|
+
results.push(record);
|
|
158
|
+
const id = idOf(record);
|
|
159
|
+
if (id) created.push({ object: op.object, id });
|
|
160
|
+
} else if (action === "update") {
|
|
161
|
+
if (op.id == null) {
|
|
162
|
+
throw new Error(`batchTransaction: update on "${op.object}" requires an id`);
|
|
163
|
+
}
|
|
164
|
+
results.push(
|
|
165
|
+
await dataSource.update(
|
|
166
|
+
op.object,
|
|
167
|
+
op.id,
|
|
168
|
+
resolveRefs(op.data ?? {}, results)
|
|
169
|
+
)
|
|
170
|
+
);
|
|
171
|
+
} else {
|
|
172
|
+
if (op.id == null) {
|
|
173
|
+
throw new Error(`batchTransaction: delete on "${op.object}" requires an id`);
|
|
174
|
+
}
|
|
175
|
+
results.push(await dataSource.delete(op.object, op.id));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { results };
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (created.length > 0) {
|
|
181
|
+
await Promise.allSettled(
|
|
182
|
+
[...created].reverse().map((c) => dataSource.delete(c.object, c.id))
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
122
189
|
// ../core/src/utils/reference-keys.ts
|
|
123
190
|
function normalizeFieldReferenceKeys(fieldDef) {
|
|
124
191
|
if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
|
|
@@ -319,893 +386,898 @@ var MetadataCache = class {
|
|
|
319
386
|
}
|
|
320
387
|
};
|
|
321
388
|
|
|
322
|
-
// src/
|
|
323
|
-
var
|
|
389
|
+
// src/metadata-client.ts
|
|
390
|
+
var META_PREFIX = "/meta";
|
|
391
|
+
var API_PREFIX = "/api/v1";
|
|
392
|
+
function buildBase(config) {
|
|
393
|
+
const trimmed = config.baseUrl.replace(/\/+$/, "");
|
|
394
|
+
const scoped = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
|
|
395
|
+
if (/\/api\/v\d+$/i.test(trimmed)) {
|
|
396
|
+
const stripped = trimmed.replace(/\/api\/v\d+$/i, "");
|
|
397
|
+
const scopedNoApi = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
|
|
398
|
+
return `${stripped}${scopedNoApi}`;
|
|
399
|
+
}
|
|
400
|
+
return `${trimmed}${scoped}`;
|
|
401
|
+
}
|
|
402
|
+
async function parseError(res) {
|
|
403
|
+
let body;
|
|
404
|
+
try {
|
|
405
|
+
body = await res.json();
|
|
406
|
+
} catch {
|
|
407
|
+
body = await res.text().catch(() => void 0);
|
|
408
|
+
}
|
|
409
|
+
const bodyObj = body && typeof body === "object" ? body : void 0;
|
|
410
|
+
const errNode = bodyObj?.error;
|
|
411
|
+
const message = (errNode && typeof errNode === "object" ? errNode.message : errNode) ?? bodyObj?.message ?? `Metadata request failed: ${res.status} ${res.statusText}`;
|
|
412
|
+
const err = new Error(String(message));
|
|
413
|
+
err.status = res.status;
|
|
414
|
+
err.body = body;
|
|
415
|
+
const details = errNode && typeof errNode === "object" ? errNode.details : void 0;
|
|
416
|
+
const issues = details?.issues ?? (errNode && typeof errNode === "object" ? errNode.issues : void 0) ?? bodyObj?.issues;
|
|
417
|
+
if (Array.isArray(issues)) err.issues = issues;
|
|
418
|
+
const code = details?.code ?? (errNode && typeof errNode === "object" ? errNode.code : void 0) ?? bodyObj?.code;
|
|
419
|
+
if (code != null) err.code = String(code);
|
|
420
|
+
return err;
|
|
421
|
+
}
|
|
422
|
+
var MetadataClient = class _MetadataClient {
|
|
423
|
+
constructor(config) {
|
|
424
|
+
__publicField(this, "base");
|
|
425
|
+
__publicField(this, "fetchImpl");
|
|
426
|
+
__publicField(this, "headers");
|
|
427
|
+
/** ADR-0037: when true, reads render the draft-overlaid world. */
|
|
428
|
+
__publicField(this, "previewDrafts");
|
|
429
|
+
this.base = buildBase(config);
|
|
430
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
431
|
+
this.headers = { Accept: "application/json", ...config.headers ?? {} };
|
|
432
|
+
this.previewDrafts = config.previewDrafts === true;
|
|
433
|
+
}
|
|
434
|
+
/** Update the client's environment scope at runtime. */
|
|
435
|
+
withEnvironment(environmentId) {
|
|
436
|
+
return new _MetadataClient({
|
|
437
|
+
baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
|
|
438
|
+
environmentId,
|
|
439
|
+
fetch: this.fetchImpl,
|
|
440
|
+
headers: this.headers,
|
|
441
|
+
previewDrafts: this.previewDrafts
|
|
442
|
+
});
|
|
443
|
+
}
|
|
324
444
|
/**
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
* @param code - Unique error code for programmatic handling
|
|
329
|
-
* @param statusCode - Optional HTTP status code
|
|
330
|
-
* @param details - Optional additional error details for debugging
|
|
445
|
+
* Derive a client whose reads render the draft-overlaid world (or not).
|
|
446
|
+
* Same base/fetch/headers; used by the app-shell to switch the whole
|
|
447
|
+
* renderer tree into ADR-0037 preview mode off one URL flag.
|
|
331
448
|
*/
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
449
|
+
withPreviewDrafts(previewDrafts) {
|
|
450
|
+
if (previewDrafts === this.previewDrafts) return this;
|
|
451
|
+
const envMatch = this.base.match(/\/api\/v\d+\/environments\/([^/]+)\/meta$/);
|
|
452
|
+
return new _MetadataClient({
|
|
453
|
+
baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
|
|
454
|
+
...envMatch ? { environmentId: decodeURIComponent(envMatch[1]) } : {},
|
|
455
|
+
fetch: this.fetchImpl,
|
|
456
|
+
headers: this.headers,
|
|
457
|
+
previewDrafts
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
/** List all registered metadata types (returns the registry rows). */
|
|
461
|
+
async listTypes() {
|
|
462
|
+
const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
463
|
+
if (!res.ok) throw await parseError(res);
|
|
464
|
+
const data = await res.json();
|
|
465
|
+
if (Array.isArray(data)) return data;
|
|
466
|
+
if (data && Array.isArray(data.entries)) return data.entries;
|
|
467
|
+
if (data && Array.isArray(data.items)) return data.items;
|
|
468
|
+
if (data && Array.isArray(data.types)) {
|
|
469
|
+
return data.types.map((t) => ({ type: t }));
|
|
470
|
+
}
|
|
471
|
+
return [];
|
|
472
|
+
}
|
|
473
|
+
/** List items of a metadata type (e.g. `object`, `field`, `view`). */
|
|
474
|
+
async list(type, options = {}) {
|
|
475
|
+
const params = [];
|
|
476
|
+
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
477
|
+
if (this.previewDrafts) params.push("preview=draft");
|
|
478
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
479
|
+
const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
|
|
480
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
481
|
+
if (!res.ok) throw await parseError(res);
|
|
482
|
+
const data = await res.json();
|
|
483
|
+
if (Array.isArray(data)) return data;
|
|
484
|
+
if (data && Array.isArray(data.items)) {
|
|
485
|
+
return data.items;
|
|
340
486
|
}
|
|
487
|
+
return [];
|
|
341
488
|
}
|
|
342
489
|
/**
|
|
343
|
-
*
|
|
490
|
+
* List pending DRAFT items (ADR-0033) — what an AI authored but nobody
|
|
491
|
+
* published yet. `list()` only sees published/active metadata, so a
|
|
492
|
+
* just-built app package looks empty there; this surfaces the drafts so the
|
|
493
|
+
* console can show a "pending changes" view and draft-aware package contents.
|
|
494
|
+
* Optionally narrow by `packageId` and/or `type`. Returns light headers
|
|
495
|
+
* (no body) carrying `packageId` for grouping.
|
|
344
496
|
*/
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
497
|
+
async listDrafts(options = {}) {
|
|
498
|
+
const params = [];
|
|
499
|
+
if (options.packageId) params.push(`packageId=${encodeURIComponent(options.packageId)}`);
|
|
500
|
+
if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
|
|
501
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
502
|
+
const url = `${this.base}/_drafts${qs}`;
|
|
503
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
504
|
+
if (!res.ok) throw await parseError(res);
|
|
505
|
+
const data = await res.json();
|
|
506
|
+
if (Array.isArray(data)) return data;
|
|
507
|
+
return data?.drafts ?? data?.data?.drafts ?? [];
|
|
354
508
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
);
|
|
364
|
-
|
|
509
|
+
/**
|
|
510
|
+
* Get a single metadata item. Returns the unwrapped item content
|
|
511
|
+
* (matching the framework REST handler which calls `res.json(item)`).
|
|
512
|
+
* Returns `null` on 404 to keep the call-site ergonomic.
|
|
513
|
+
*/
|
|
514
|
+
async get(type, name, options = {}) {
|
|
515
|
+
const params = [];
|
|
516
|
+
if (options.state === "draft") params.push("state=draft");
|
|
517
|
+
else if (this.previewDrafts) params.push("preview=draft");
|
|
518
|
+
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
519
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
520
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
521
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
522
|
+
if (res.status === 404) return null;
|
|
523
|
+
if (!res.ok) throw await parseError(res);
|
|
524
|
+
return await res.json();
|
|
365
525
|
}
|
|
366
|
-
};
|
|
367
|
-
var BulkOperationError = class extends ObjectStackError {
|
|
368
526
|
/**
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
527
|
+
* Read the pending draft body for an item (`?state=draft`). Returns
|
|
528
|
+
* `null` when there is no draft pending. Draft reads do NOT fall
|
|
529
|
+
* back to the published overlay or the artifact registry — a `null`
|
|
530
|
+
* unambiguously means "nothing to publish".
|
|
531
|
+
*
|
|
532
|
+
* Note: the framework wraps draft responses in an envelope
|
|
533
|
+
* `{ type, name, item }` (matching `getMetaItem`); callers should
|
|
534
|
+
* read `.item` to get the body. The legacy `get()` returns the
|
|
535
|
+
* unwrapped body, so this method preserves that asymmetry by
|
|
536
|
+
* returning whatever the server sent.
|
|
376
537
|
*/
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
`Bulk ${operation} operation failed: ${successCount} succeeded, ${failureCount} failed`,
|
|
380
|
-
"BULK_OPERATION_ERROR",
|
|
381
|
-
500,
|
|
382
|
-
{
|
|
383
|
-
operation,
|
|
384
|
-
successCount,
|
|
385
|
-
failureCount,
|
|
386
|
-
errors,
|
|
387
|
-
...details
|
|
388
|
-
}
|
|
389
|
-
);
|
|
390
|
-
__publicField(this, "successCount", successCount);
|
|
391
|
-
__publicField(this, "failureCount", failureCount);
|
|
392
|
-
__publicField(this, "errors", errors);
|
|
393
|
-
this.name = "BulkOperationError";
|
|
538
|
+
async getDraft(type, name, options = {}) {
|
|
539
|
+
return this.get(type, name, { state: "draft", ...options.packageId ? { packageId: options.packageId } : {} });
|
|
394
540
|
}
|
|
395
541
|
/**
|
|
396
|
-
*
|
|
542
|
+
* Save (PUT) a metadata item. The framework accepts both the bare
|
|
543
|
+
* item payload and the `{ item: ... }` / `{ metadata: ... }`
|
|
544
|
+
* envelopes; we send bare for consistency. Pass `mode: 'draft'` to
|
|
545
|
+
* stage the change without publishing (Studio's "Save" button).
|
|
397
546
|
*/
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
547
|
+
async save(type, name, item, options = {}) {
|
|
548
|
+
if (!name || !String(name).trim()) {
|
|
549
|
+
throw new Error(
|
|
550
|
+
`MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
const params = [];
|
|
554
|
+
if (options.force) params.push("force=true");
|
|
555
|
+
if (options.mode === "draft") params.push("mode=draft");
|
|
556
|
+
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
557
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
558
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
559
|
+
const headers = {
|
|
560
|
+
...this.headers,
|
|
561
|
+
"Content-Type": "application/json"
|
|
408
562
|
};
|
|
563
|
+
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
564
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
565
|
+
const res = await this.fetchImpl(url, {
|
|
566
|
+
method: "PUT",
|
|
567
|
+
headers,
|
|
568
|
+
body: JSON.stringify(item)
|
|
569
|
+
});
|
|
570
|
+
if (!res.ok) throw await parseError(res);
|
|
571
|
+
return await res.json();
|
|
409
572
|
}
|
|
410
|
-
};
|
|
411
|
-
var ConnectionError = class extends ObjectStackError {
|
|
412
|
-
constructor(message, url, details, statusCode) {
|
|
413
|
-
super(
|
|
414
|
-
`Connection error: ${message}`,
|
|
415
|
-
"CONNECTION_ERROR",
|
|
416
|
-
statusCode || 503,
|
|
417
|
-
{ url, ...details }
|
|
418
|
-
);
|
|
419
|
-
__publicField(this, "url", url);
|
|
420
|
-
this.name = "ConnectionError";
|
|
421
|
-
}
|
|
422
|
-
};
|
|
423
|
-
var AuthenticationError = class extends ObjectStackError {
|
|
424
|
-
constructor(message = "Authentication failed", details, statusCode) {
|
|
425
|
-
super(
|
|
426
|
-
message,
|
|
427
|
-
"AUTHENTICATION_ERROR",
|
|
428
|
-
statusCode || 401,
|
|
429
|
-
details
|
|
430
|
-
);
|
|
431
|
-
this.name = "AuthenticationError";
|
|
432
|
-
}
|
|
433
|
-
};
|
|
434
|
-
var ValidationError = class extends ObjectStackError {
|
|
435
573
|
/**
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
574
|
+
* Publish a single pending draft BY REFERENCE — promotes the draft row for
|
|
575
|
+
* `(type, name)` into the active overlay and drops the draft. Works for ANY
|
|
576
|
+
* draft, including ones with no `packageId` binding (which the package-scoped
|
|
577
|
+
* `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
|
|
578
|
+
* exact set returned by {@link listDrafts} without needing a package.
|
|
579
|
+
*
|
|
580
|
+
* Returns the server result. For `seed` drafts the protocol also materializes
|
|
581
|
+
* the rows and reports under `seedApplied` — a data problem never fails the
|
|
582
|
+
* publish, so callers should check `seedApplied?.success` and warn the user
|
|
583
|
+
* rather than assume the data went live.
|
|
442
584
|
*/
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
"
|
|
447
|
-
|
|
448
|
-
{
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
__publicField(this, "field", field);
|
|
455
|
-
__publicField(this, "validationErrors", validationErrors);
|
|
456
|
-
this.name = "ValidationError";
|
|
585
|
+
async publishDraft(type, name) {
|
|
586
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
|
|
587
|
+
const res = await this.fetchImpl(url, {
|
|
588
|
+
method: "POST",
|
|
589
|
+
headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
|
|
590
|
+
body: "{}"
|
|
591
|
+
});
|
|
592
|
+
if (!res.ok) throw await parseError(res);
|
|
593
|
+
const body = await res.json().catch(() => ({}));
|
|
594
|
+
const inner = body?.data && typeof body.data === "object" ? body.data : body;
|
|
595
|
+
return inner;
|
|
457
596
|
}
|
|
458
597
|
/**
|
|
459
|
-
* Get
|
|
598
|
+
* Get the 3-state layered view of a metadata item (Phase 3a). Returns
|
|
599
|
+
* `code` (the artifact / fallback default), `overlay` (the saved
|
|
600
|
+
* customisation, if any), and `effective` (what the runtime sees).
|
|
460
601
|
*/
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
const message = response?.message || response?.statusText || "Unknown error";
|
|
468
|
-
const details = {
|
|
469
|
-
context,
|
|
470
|
-
response: {
|
|
471
|
-
status,
|
|
472
|
-
data: response?.data,
|
|
473
|
-
headers: response?.headers
|
|
602
|
+
async layered(type, name, options = {}) {
|
|
603
|
+
const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : "";
|
|
604
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
|
|
605
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
606
|
+
if (res.status === 404) {
|
|
607
|
+
return { code: null, overlay: null, overlayScope: null, effective: null };
|
|
474
608
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
505
|
-
function isErrorType(error, errorClass) {
|
|
506
|
-
return error instanceof errorClass;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// src/cloud.ts
|
|
510
|
-
var CloudOperations = class {
|
|
511
|
-
constructor(getClient) {
|
|
512
|
-
__publicField(this, "getClient", getClient);
|
|
609
|
+
if (!res.ok) throw await parseError(res);
|
|
610
|
+
const body = await res.json();
|
|
611
|
+
const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
|
|
612
|
+
if (!hasEnvelope) {
|
|
613
|
+
return {
|
|
614
|
+
code: null,
|
|
615
|
+
overlay: null,
|
|
616
|
+
overlayScope: null,
|
|
617
|
+
effective: body
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
code: body.code ?? null,
|
|
622
|
+
overlay: body.overlay ?? null,
|
|
623
|
+
overlayScope: body.overlayScope ?? null,
|
|
624
|
+
effective: body.effective ?? null,
|
|
625
|
+
...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
|
|
626
|
+
// ADR-0010 Phase 4 — pass through the metadata-protection envelope so
|
|
627
|
+
// the editor can render lock affordances without a second round trip.
|
|
628
|
+
...body.lock !== void 0 ? { lock: body.lock } : {},
|
|
629
|
+
...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
|
|
630
|
+
...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
|
|
631
|
+
...body.lockDocsUrl !== void 0 ? { lockDocsUrl: body.lockDocsUrl } : {},
|
|
632
|
+
...body.provenance !== void 0 ? { provenance: body.provenance } : {},
|
|
633
|
+
...body.packageId !== void 0 ? { packageId: body.packageId } : {},
|
|
634
|
+
...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
|
|
635
|
+
...body.editable !== void 0 ? { editable: body.editable } : {},
|
|
636
|
+
...body.deletable !== void 0 ? { deletable: body.deletable } : {},
|
|
637
|
+
...body.resettable !== void 0 ? { resettable: body.resettable } : {}
|
|
638
|
+
};
|
|
513
639
|
}
|
|
514
640
|
/**
|
|
515
|
-
*
|
|
641
|
+
* Cross-type sweep of load-time validation results — calls
|
|
642
|
+
* `GET /meta/diagnostics`. Returns every entry the framework
|
|
643
|
+
* considers invalid (or, when `severity: 'warning'`, also entries
|
|
644
|
+
* with only warnings).
|
|
645
|
+
*
|
|
646
|
+
* Used by the Studio's governance overview page and by the
|
|
647
|
+
* directory page to show "N invalid" badges per metadata type.
|
|
516
648
|
*/
|
|
517
|
-
async
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
649
|
+
async diagnostics(options = {}) {
|
|
650
|
+
const params = [];
|
|
651
|
+
if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
|
|
652
|
+
if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
|
|
653
|
+
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
654
|
+
const qs = params.length ? `?${params.join("&")}` : "";
|
|
655
|
+
const url = `${this.base}/diagnostics${qs}`;
|
|
656
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
657
|
+
if (res.status === 404) {
|
|
658
|
+
return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
|
|
659
|
+
}
|
|
660
|
+
if (!res.ok) throw await parseError(res);
|
|
661
|
+
const data = await res.json();
|
|
662
|
+
return {
|
|
663
|
+
entries: Array.isArray(data.entries) ? data.entries : [],
|
|
664
|
+
total: typeof data.total === "number" ? data.total : 0,
|
|
665
|
+
scannedTypes: typeof data.scannedTypes === "number" ? data.scannedTypes : 0,
|
|
666
|
+
scannedItems: typeof data.scannedItems === "number" ? data.scannedItems : 0,
|
|
667
|
+
stats: data.stats && typeof data.stats === "object" ? data.stats : {}
|
|
668
|
+
};
|
|
521
669
|
}
|
|
522
670
|
/**
|
|
523
|
-
*
|
|
671
|
+
* Find every metadata item that references this one (Phase 3a). Useful
|
|
672
|
+
* for pre-delete impact analysis: "Are any views pointing at this
|
|
673
|
+
* object before I drop it?".
|
|
524
674
|
*/
|
|
525
|
-
async
|
|
526
|
-
const
|
|
527
|
-
const
|
|
528
|
-
|
|
675
|
+
async references(type, name) {
|
|
676
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
|
|
677
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
678
|
+
if (res.status === 404) return [];
|
|
679
|
+
if (!res.ok) throw await parseError(res);
|
|
680
|
+
const data = await res.json();
|
|
681
|
+
return Array.isArray(data) ? data : data?.items ?? [];
|
|
529
682
|
}
|
|
530
683
|
/**
|
|
531
|
-
*
|
|
684
|
+
* Reset a metadata customization overlay back to the artifact default.
|
|
685
|
+
* Idempotent: returns the result even when no overlay row existed.
|
|
686
|
+
* Pass `state: 'draft'` to discard the pending draft only (keeps the
|
|
687
|
+
* published overlay intact) — useful for a Studio "Discard draft" button.
|
|
532
688
|
*/
|
|
533
|
-
async
|
|
534
|
-
const
|
|
535
|
-
const
|
|
536
|
-
|
|
689
|
+
async reset(type, name, options = {}) {
|
|
690
|
+
const qs = options.state === "draft" ? "?state=draft" : "";
|
|
691
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
692
|
+
const headers = { ...this.headers };
|
|
693
|
+
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
694
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
695
|
+
const res = await this.fetchImpl(url, { method: "DELETE", headers });
|
|
696
|
+
if (!res.ok) throw await parseError(res);
|
|
697
|
+
return await res.json();
|
|
537
698
|
}
|
|
538
699
|
/**
|
|
539
|
-
*
|
|
700
|
+
* Promote the pending draft of an item to the active overlay
|
|
701
|
+
* (POST `/meta/:type/:name/publish`). Returns
|
|
702
|
+
* `{ success, version, seq, message }`. Throws a `404 no_draft` if
|
|
703
|
+
* nothing is pending and `409 metadata_conflict` if the published
|
|
704
|
+
* overlay moved while the draft was sitting.
|
|
540
705
|
*/
|
|
541
|
-
async
|
|
542
|
-
const
|
|
543
|
-
const
|
|
544
|
-
|
|
706
|
+
async publish(type, name, options = {}) {
|
|
707
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
|
|
708
|
+
const headers = {
|
|
709
|
+
...this.headers,
|
|
710
|
+
"Content-Type": "application/json"
|
|
711
|
+
};
|
|
712
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
713
|
+
const res = await this.fetchImpl(url, {
|
|
714
|
+
method: "POST",
|
|
715
|
+
headers,
|
|
716
|
+
body: JSON.stringify(options.message ? { message: options.message } : {})
|
|
717
|
+
});
|
|
718
|
+
if (!res.ok) throw await parseError(res);
|
|
719
|
+
return await res.json();
|
|
545
720
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
errors.push({ field: "exports", message: "At least one export is required", code: "NO_EXPORTS" });
|
|
560
|
-
}
|
|
561
|
-
if (contract.exports) {
|
|
562
|
-
const validTypes = ["component", "hook", "utility", "provider"];
|
|
563
|
-
for (const exp of contract.exports) {
|
|
564
|
-
if (!exp.name) {
|
|
565
|
-
errors.push({ field: "exports.name", message: "Export name is required", code: "MISSING_EXPORT_NAME" });
|
|
566
|
-
}
|
|
567
|
-
if (!validTypes.includes(exp.type)) {
|
|
568
|
-
errors.push({ field: "exports.type", message: `Invalid export type: ${exp.type}`, code: "INVALID_EXPORT_TYPE" });
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
if (!contract.permissions || contract.permissions.length === 0) {
|
|
573
|
-
warnings.push("No permissions declared \u2014 plugin will have minimal access");
|
|
574
|
-
}
|
|
575
|
-
return { valid: errors.length === 0, errors, warnings };
|
|
576
|
-
}
|
|
577
|
-
function generateContractManifest(contract) {
|
|
578
|
-
return {
|
|
579
|
-
$schema: "https://objectui.org/schemas/plugin-contract-v1.json",
|
|
580
|
-
name: contract.name,
|
|
581
|
-
version: contract.version,
|
|
582
|
-
peerDependencies: contract.peerDependencies ?? {},
|
|
583
|
-
exports: contract.exports.map((exp) => ({
|
|
584
|
-
name: exp.name,
|
|
585
|
-
type: exp.type,
|
|
586
|
-
description: exp.description ?? ""
|
|
587
|
-
})),
|
|
588
|
-
permissions: contract.permissions ?? [],
|
|
589
|
-
api: contract.api ?? {},
|
|
590
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
591
|
-
};
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// src/userState.ts
|
|
595
|
-
var DEFAULT_RESOURCE = "sys_user_preference";
|
|
596
|
-
function createObjectStackUserStateAdapter(options) {
|
|
597
|
-
const {
|
|
598
|
-
dataSource,
|
|
599
|
-
userId,
|
|
600
|
-
key,
|
|
601
|
-
resource = DEFAULT_RESOURCE,
|
|
602
|
-
onError = () => {
|
|
603
|
-
}
|
|
604
|
-
} = options;
|
|
605
|
-
let cachedRowId = null;
|
|
606
|
-
let saveChain = Promise.resolve();
|
|
607
|
-
const findExisting = async () => {
|
|
608
|
-
const params = {
|
|
609
|
-
$filter: {
|
|
610
|
-
user_id: userId,
|
|
611
|
-
key
|
|
612
|
-
},
|
|
613
|
-
$top: 1
|
|
721
|
+
/**
|
|
722
|
+
* Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
|
|
723
|
+
* The server reads the history row at `toVersion`, writes its body back
|
|
724
|
+
* as the active overlay with `operation_type='revert'`. Returns
|
|
725
|
+
* `{ success, version, seq, restoredFromVersion, message }`. Throws
|
|
726
|
+
* `404 version_not_found` for unknown versions and `409 version_not_restorable`
|
|
727
|
+
* when the target version is a delete tombstone.
|
|
728
|
+
*/
|
|
729
|
+
async rollback(type, name, toVersion, options = {}) {
|
|
730
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
|
|
731
|
+
const headers = {
|
|
732
|
+
...this.headers,
|
|
733
|
+
"Content-Type": "application/json"
|
|
614
734
|
};
|
|
615
|
-
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
735
|
+
if (options.actor) headers["X-Actor"] = options.actor;
|
|
736
|
+
const res = await this.fetchImpl(url, {
|
|
737
|
+
method: "POST",
|
|
738
|
+
headers,
|
|
739
|
+
body: JSON.stringify({
|
|
740
|
+
toVersion,
|
|
741
|
+
...options.message ? { message: options.message } : {}
|
|
742
|
+
})
|
|
622
743
|
});
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
const decodeValue = (value) => {
|
|
626
|
-
if (Array.isArray(value)) return value;
|
|
627
|
-
if (typeof value === "string") {
|
|
628
|
-
try {
|
|
629
|
-
const parsed = JSON.parse(value);
|
|
630
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
631
|
-
} catch {
|
|
632
|
-
return [];
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
return [];
|
|
636
|
-
};
|
|
637
|
-
const upsert = async (items) => {
|
|
638
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
639
|
-
if (cachedRowId !== null) {
|
|
640
|
-
try {
|
|
641
|
-
await dataSource.update(resource, cachedRowId, { value: items, updated_at: now });
|
|
642
|
-
return;
|
|
643
|
-
} catch (updateError) {
|
|
644
|
-
cachedRowId = null;
|
|
645
|
-
onError("save", updateError);
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
const existing = await findExisting();
|
|
649
|
-
if (existing && existing.id !== void 0 && existing.id !== null) {
|
|
650
|
-
cachedRowId = existing.id;
|
|
651
|
-
await dataSource.update(resource, existing.id, { value: items, updated_at: now });
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
try {
|
|
655
|
-
const created = await dataSource.create(resource, {
|
|
656
|
-
user_id: userId,
|
|
657
|
-
key,
|
|
658
|
-
value: items,
|
|
659
|
-
updated_at: now
|
|
660
|
-
});
|
|
661
|
-
const newId = created?.id;
|
|
662
|
-
if (newId !== void 0 && newId !== null) cachedRowId = newId;
|
|
663
|
-
} catch (createError) {
|
|
664
|
-
const recovered = await findExisting();
|
|
665
|
-
if (recovered && recovered.id !== void 0 && recovered.id !== null) {
|
|
666
|
-
cachedRowId = recovered.id;
|
|
667
|
-
await dataSource.update(resource, recovered.id, { value: items, updated_at: now });
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
|
-
throw createError;
|
|
671
|
-
}
|
|
672
|
-
};
|
|
673
|
-
return {
|
|
674
|
-
async load() {
|
|
675
|
-
try {
|
|
676
|
-
const row = await findExisting();
|
|
677
|
-
if (!row) {
|
|
678
|
-
cachedRowId = null;
|
|
679
|
-
return [];
|
|
680
|
-
}
|
|
681
|
-
if (row.id !== void 0 && row.id !== null) cachedRowId = row.id;
|
|
682
|
-
return decodeValue(row.value);
|
|
683
|
-
} catch (error) {
|
|
684
|
-
onError("load", error);
|
|
685
|
-
return [];
|
|
686
|
-
}
|
|
687
|
-
},
|
|
688
|
-
async save(items) {
|
|
689
|
-
const run = saveChain.then(() => upsert(items)).catch((error) => {
|
|
690
|
-
onError("save", error);
|
|
691
|
-
});
|
|
692
|
-
saveChain = run;
|
|
693
|
-
return run;
|
|
694
|
-
}
|
|
695
|
-
};
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// src/integration.ts
|
|
699
|
-
var IntegrationManager = class {
|
|
700
|
-
constructor() {
|
|
701
|
-
__publicField(this, "integrations", /* @__PURE__ */ new Map());
|
|
744
|
+
if (!res.ok) throw await parseError(res);
|
|
745
|
+
return await res.json();
|
|
702
746
|
}
|
|
703
747
|
/**
|
|
704
|
-
*
|
|
748
|
+
* Compute a structured top-level key diff between two history versions
|
|
749
|
+
* (GET `/meta/:type/:name/diff?from=&to=`). Returns
|
|
750
|
+
* `{ added, removed, changed }` where each entry carries a `path` and
|
|
751
|
+
* the relevant value(s). Useful for "what changed in this draft?" and
|
|
752
|
+
* pre-rollback previews.
|
|
705
753
|
*/
|
|
706
|
-
|
|
707
|
-
|
|
754
|
+
async diff(type, name, fromVersion, toVersion) {
|
|
755
|
+
const params = new URLSearchParams();
|
|
756
|
+
if (fromVersion !== void 0) params.set("from", String(fromVersion));
|
|
757
|
+
if (toVersion !== void 0) params.set("to", String(toVersion));
|
|
758
|
+
const qs = params.toString();
|
|
759
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ""}`;
|
|
760
|
+
const res = await this.fetchImpl(url, {
|
|
761
|
+
method: "GET",
|
|
762
|
+
headers: this.headers,
|
|
763
|
+
cache: "no-store"
|
|
764
|
+
});
|
|
765
|
+
if (!res.ok) throw await parseError(res);
|
|
766
|
+
return await res.json();
|
|
708
767
|
}
|
|
709
|
-
/**
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
768
|
+
/** Fetch the durable history (change log) for one metadata item. */
|
|
769
|
+
async history(type, name, options = {}) {
|
|
770
|
+
const params = new URLSearchParams();
|
|
771
|
+
if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
|
|
772
|
+
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
773
|
+
const qs = params.toString();
|
|
774
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
|
|
775
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
776
|
+
if (!res.ok) throw await parseError(res);
|
|
777
|
+
return await res.json();
|
|
714
778
|
}
|
|
715
779
|
/**
|
|
716
|
-
*
|
|
780
|
+
* Fetch the protection-audit trail for one metadata item
|
|
781
|
+
* (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
|
|
782
|
+
* delete/reset attempt — allowed, denied, or forced — so the
|
|
783
|
+
* Studio "审计日志 / Audit log" tab can render who tried what
|
|
784
|
+
* and whether a lock blocked it.
|
|
717
785
|
*/
|
|
718
|
-
|
|
719
|
-
|
|
786
|
+
async audit(type, name, options = {}) {
|
|
787
|
+
const params = new URLSearchParams();
|
|
788
|
+
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
789
|
+
const qs = params.toString();
|
|
790
|
+
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ""}`;
|
|
791
|
+
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
792
|
+
if (!res.ok) throw await parseError(res);
|
|
793
|
+
return await res.json();
|
|
720
794
|
}
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
// src/errors.ts
|
|
798
|
+
var ObjectStackError = class extends Error {
|
|
721
799
|
/**
|
|
722
|
-
*
|
|
800
|
+
* Create a new ObjectStackError
|
|
801
|
+
*
|
|
802
|
+
* @param message - Human-readable error message
|
|
803
|
+
* @param code - Unique error code for programmatic handling
|
|
804
|
+
* @param statusCode - Optional HTTP status code
|
|
805
|
+
* @param details - Optional additional error details for debugging
|
|
723
806
|
*/
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
);
|
|
807
|
+
constructor(message, code, statusCode, details) {
|
|
808
|
+
super(message);
|
|
809
|
+
__publicField(this, "code", code);
|
|
810
|
+
__publicField(this, "statusCode", statusCode);
|
|
811
|
+
__publicField(this, "details", details);
|
|
812
|
+
this.name = "ObjectStackError";
|
|
813
|
+
if (Error.captureStackTrace) {
|
|
814
|
+
Error.captureStackTrace(this, this.constructor);
|
|
815
|
+
}
|
|
728
816
|
}
|
|
729
817
|
/**
|
|
730
|
-
*
|
|
731
|
-
* Returns results for each integration.
|
|
818
|
+
* Convert error to JSON for logging/debugging
|
|
732
819
|
*/
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
820
|
+
toJSON() {
|
|
821
|
+
return {
|
|
822
|
+
name: this.name,
|
|
823
|
+
message: this.message,
|
|
824
|
+
code: this.code,
|
|
825
|
+
statusCode: this.statusCode,
|
|
826
|
+
details: this.details,
|
|
827
|
+
stack: this.stack
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
var MetadataNotFoundError = class extends ObjectStackError {
|
|
832
|
+
constructor(objectName, details) {
|
|
833
|
+
super(
|
|
834
|
+
`Metadata not found for object: ${objectName}`,
|
|
835
|
+
"METADATA_NOT_FOUND",
|
|
836
|
+
404,
|
|
837
|
+
{ objectName, ...details }
|
|
838
|
+
);
|
|
839
|
+
this.name = "MetadataNotFoundError";
|
|
746
840
|
}
|
|
841
|
+
};
|
|
842
|
+
var BulkOperationError = class extends ObjectStackError {
|
|
747
843
|
/**
|
|
748
|
-
*
|
|
844
|
+
* Create a new BulkOperationError
|
|
845
|
+
*
|
|
846
|
+
* @param operation - The bulk operation that failed (create, update, delete)
|
|
847
|
+
* @param successCount - Number of successful operations
|
|
848
|
+
* @param failureCount - Number of failed operations
|
|
849
|
+
* @param errors - Array of individual errors
|
|
850
|
+
* @param details - Additional error details
|
|
749
851
|
*/
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
if (e instanceof TypeError) {
|
|
762
|
-
throw new Error(`Invalid webhook URL: ${url}`);
|
|
763
|
-
}
|
|
764
|
-
throw e;
|
|
765
|
-
}
|
|
766
|
-
await fetch(url, {
|
|
767
|
-
method: cfg.method,
|
|
768
|
-
headers: {
|
|
769
|
-
"Content-Type": "application/json",
|
|
770
|
-
...cfg.headers
|
|
771
|
-
},
|
|
772
|
-
body: JSON.stringify(payload)
|
|
773
|
-
});
|
|
774
|
-
break;
|
|
775
|
-
}
|
|
776
|
-
case "slack": {
|
|
777
|
-
const cfg = integration.config;
|
|
778
|
-
const url = cfg.webhookUrl;
|
|
779
|
-
try {
|
|
780
|
-
const parsed = new URL(url);
|
|
781
|
-
if (parsed.protocol !== "https:") {
|
|
782
|
-
throw new Error(`Invalid Slack webhook URL protocol: ${parsed.protocol}`);
|
|
783
|
-
}
|
|
784
|
-
} catch (e) {
|
|
785
|
-
if (e instanceof TypeError) {
|
|
786
|
-
throw new Error(`Invalid Slack webhook URL: ${url}`);
|
|
787
|
-
}
|
|
788
|
-
throw e;
|
|
789
|
-
}
|
|
790
|
-
await fetch(url, {
|
|
791
|
-
method: "POST",
|
|
792
|
-
headers: { "Content-Type": "application/json" },
|
|
793
|
-
body: JSON.stringify({
|
|
794
|
-
channel: cfg.channel,
|
|
795
|
-
username: cfg.username,
|
|
796
|
-
icon_emoji: cfg.iconEmoji,
|
|
797
|
-
text: JSON.stringify(payload)
|
|
798
|
-
})
|
|
799
|
-
});
|
|
800
|
-
break;
|
|
852
|
+
constructor(operation, successCount, failureCount, errors, details) {
|
|
853
|
+
super(
|
|
854
|
+
`Bulk ${operation} operation failed: ${successCount} succeeded, ${failureCount} failed`,
|
|
855
|
+
"BULK_OPERATION_ERROR",
|
|
856
|
+
500,
|
|
857
|
+
{
|
|
858
|
+
operation,
|
|
859
|
+
successCount,
|
|
860
|
+
failureCount,
|
|
861
|
+
errors,
|
|
862
|
+
...details
|
|
801
863
|
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
};
|
|
808
|
-
|
|
809
|
-
// src/metadata-client.ts
|
|
810
|
-
var META_PREFIX = "/meta";
|
|
811
|
-
var API_PREFIX = "/api/v1";
|
|
812
|
-
function buildBase(config) {
|
|
813
|
-
const trimmed = config.baseUrl.replace(/\/+$/, "");
|
|
814
|
-
const scoped = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
|
|
815
|
-
if (/\/api\/v\d+$/i.test(trimmed)) {
|
|
816
|
-
const stripped = trimmed.replace(/\/api\/v\d+$/i, "");
|
|
817
|
-
const scopedNoApi = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
|
|
818
|
-
return `${stripped}${scopedNoApi}`;
|
|
819
|
-
}
|
|
820
|
-
return `${trimmed}${scoped}`;
|
|
821
|
-
}
|
|
822
|
-
async function parseError(res) {
|
|
823
|
-
let body;
|
|
824
|
-
try {
|
|
825
|
-
body = await res.json();
|
|
826
|
-
} catch {
|
|
827
|
-
body = await res.text().catch(() => void 0);
|
|
828
|
-
}
|
|
829
|
-
const bodyObj = body && typeof body === "object" ? body : void 0;
|
|
830
|
-
const errNode = bodyObj?.error;
|
|
831
|
-
const message = (errNode && typeof errNode === "object" ? errNode.message : errNode) ?? bodyObj?.message ?? `Metadata request failed: ${res.status} ${res.statusText}`;
|
|
832
|
-
const err = new Error(String(message));
|
|
833
|
-
err.status = res.status;
|
|
834
|
-
err.body = body;
|
|
835
|
-
const details = errNode && typeof errNode === "object" ? errNode.details : void 0;
|
|
836
|
-
const issues = details?.issues ?? (errNode && typeof errNode === "object" ? errNode.issues : void 0) ?? bodyObj?.issues;
|
|
837
|
-
if (Array.isArray(issues)) err.issues = issues;
|
|
838
|
-
const code = details?.code ?? (errNode && typeof errNode === "object" ? errNode.code : void 0) ?? bodyObj?.code;
|
|
839
|
-
if (code != null) err.code = String(code);
|
|
840
|
-
return err;
|
|
841
|
-
}
|
|
842
|
-
var MetadataClient = class _MetadataClient {
|
|
843
|
-
constructor(config) {
|
|
844
|
-
__publicField(this, "base");
|
|
845
|
-
__publicField(this, "fetchImpl");
|
|
846
|
-
__publicField(this, "headers");
|
|
847
|
-
/** ADR-0037: when true, reads render the draft-overlaid world. */
|
|
848
|
-
__publicField(this, "previewDrafts");
|
|
849
|
-
this.base = buildBase(config);
|
|
850
|
-
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
851
|
-
this.headers = { Accept: "application/json", ...config.headers ?? {} };
|
|
852
|
-
this.previewDrafts = config.previewDrafts === true;
|
|
853
|
-
}
|
|
854
|
-
/** Update the client's environment scope at runtime. */
|
|
855
|
-
withEnvironment(environmentId) {
|
|
856
|
-
return new _MetadataClient({
|
|
857
|
-
baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
|
|
858
|
-
environmentId,
|
|
859
|
-
fetch: this.fetchImpl,
|
|
860
|
-
headers: this.headers,
|
|
861
|
-
previewDrafts: this.previewDrafts
|
|
862
|
-
});
|
|
864
|
+
);
|
|
865
|
+
__publicField(this, "successCount", successCount);
|
|
866
|
+
__publicField(this, "failureCount", failureCount);
|
|
867
|
+
__publicField(this, "errors", errors);
|
|
868
|
+
this.name = "BulkOperationError";
|
|
863
869
|
}
|
|
864
870
|
/**
|
|
865
|
-
*
|
|
866
|
-
* Same base/fetch/headers; used by the app-shell to switch the whole
|
|
867
|
-
* renderer tree into ADR-0037 preview mode off one URL flag.
|
|
871
|
+
* Get a summary of the bulk operation failure
|
|
868
872
|
*/
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
const
|
|
872
|
-
return
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
873
|
+
getSummary() {
|
|
874
|
+
const total = this.successCount + this.failureCount;
|
|
875
|
+
const failureRate = total > 0 ? this.failureCount / total : 0;
|
|
876
|
+
return {
|
|
877
|
+
operation: this.details?.operation,
|
|
878
|
+
total,
|
|
879
|
+
successful: this.successCount,
|
|
880
|
+
failed: this.failureCount,
|
|
881
|
+
failureRate,
|
|
882
|
+
errors: this.errors
|
|
883
|
+
};
|
|
879
884
|
}
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
return [];
|
|
885
|
+
};
|
|
886
|
+
var ConnectionError = class extends ObjectStackError {
|
|
887
|
+
constructor(message, url, details, statusCode) {
|
|
888
|
+
super(
|
|
889
|
+
`Connection error: ${message}`,
|
|
890
|
+
"CONNECTION_ERROR",
|
|
891
|
+
statusCode || 503,
|
|
892
|
+
{ url, ...details }
|
|
893
|
+
);
|
|
894
|
+
__publicField(this, "url", url);
|
|
895
|
+
this.name = "ConnectionError";
|
|
892
896
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
if (Array.isArray(data)) return data;
|
|
904
|
-
if (data && Array.isArray(data.items)) {
|
|
905
|
-
return data.items;
|
|
906
|
-
}
|
|
907
|
-
return [];
|
|
897
|
+
};
|
|
898
|
+
var AuthenticationError = class extends ObjectStackError {
|
|
899
|
+
constructor(message = "Authentication failed", details, statusCode) {
|
|
900
|
+
super(
|
|
901
|
+
message,
|
|
902
|
+
"AUTHENTICATION_ERROR",
|
|
903
|
+
statusCode || 401,
|
|
904
|
+
details
|
|
905
|
+
);
|
|
906
|
+
this.name = "AuthenticationError";
|
|
908
907
|
}
|
|
908
|
+
};
|
|
909
|
+
var ValidationError = class extends ObjectStackError {
|
|
909
910
|
/**
|
|
910
|
-
*
|
|
911
|
-
*
|
|
912
|
-
*
|
|
913
|
-
*
|
|
914
|
-
*
|
|
915
|
-
*
|
|
911
|
+
* Create a new ValidationError
|
|
912
|
+
*
|
|
913
|
+
* @param message - Human-readable error message
|
|
914
|
+
* @param field - The field that failed validation (optional)
|
|
915
|
+
* @param validationErrors - Array of validation error details
|
|
916
|
+
* @param details - Additional error details
|
|
916
917
|
*/
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
918
|
+
constructor(message, field, validationErrors, details) {
|
|
919
|
+
super(
|
|
920
|
+
message,
|
|
921
|
+
"VALIDATION_ERROR",
|
|
922
|
+
400,
|
|
923
|
+
{
|
|
924
|
+
field,
|
|
925
|
+
validationErrors,
|
|
926
|
+
...details
|
|
927
|
+
}
|
|
928
|
+
);
|
|
929
|
+
__publicField(this, "field", field);
|
|
930
|
+
__publicField(this, "validationErrors", validationErrors);
|
|
931
|
+
this.name = "ValidationError";
|
|
928
932
|
}
|
|
929
933
|
/**
|
|
930
|
-
* Get
|
|
931
|
-
* (matching the framework REST handler which calls `res.json(item)`).
|
|
932
|
-
* Returns `null` on 404 to keep the call-site ergonomic.
|
|
934
|
+
* Get all validation errors as a formatted list
|
|
933
935
|
*/
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
936
|
+
getValidationErrors() {
|
|
937
|
+
return this.validationErrors || [];
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
function createErrorFromResponse(response, context) {
|
|
941
|
+
const status = response?.status || response?.statusCode || 500;
|
|
942
|
+
const message = response?.message || response?.statusText || "Unknown error";
|
|
943
|
+
const details = {
|
|
944
|
+
context,
|
|
945
|
+
response: {
|
|
946
|
+
status,
|
|
947
|
+
data: response?.data,
|
|
948
|
+
headers: response?.headers
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
switch (status) {
|
|
952
|
+
case 401:
|
|
953
|
+
return new AuthenticationError(message, details, 401);
|
|
954
|
+
case 403:
|
|
955
|
+
return new AuthenticationError(message, details, 403);
|
|
956
|
+
case 404:
|
|
957
|
+
if (context?.includes("metadata") || context?.includes("schema") || context?.includes("getObjectSchema")) {
|
|
958
|
+
const objectName = extractObjectName(context);
|
|
959
|
+
return new MetadataNotFoundError(objectName, details);
|
|
960
|
+
}
|
|
961
|
+
return new ObjectStackError(message, "NOT_FOUND", 404, details);
|
|
962
|
+
case 400:
|
|
963
|
+
return new ValidationError(message, void 0, response?.data?.errors, details);
|
|
964
|
+
case 503:
|
|
965
|
+
return new ConnectionError(message, response?.config?.url, details, 503);
|
|
966
|
+
case 504:
|
|
967
|
+
return new ConnectionError(message, response?.config?.url, details, 504);
|
|
968
|
+
default:
|
|
969
|
+
return new ObjectStackError(message, "UNKNOWN_ERROR", status, details);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function extractObjectName(context) {
|
|
973
|
+
if (!context) return "unknown";
|
|
974
|
+
const match = context.match(/\(([^)]+)\)/);
|
|
975
|
+
return match ? match[1] : "unknown";
|
|
976
|
+
}
|
|
977
|
+
function isObjectStackError(error) {
|
|
978
|
+
return error instanceof ObjectStackError;
|
|
979
|
+
}
|
|
980
|
+
function isErrorType(error, errorClass) {
|
|
981
|
+
return error instanceof errorClass;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// src/cloud.ts
|
|
985
|
+
var CloudOperations = class {
|
|
986
|
+
constructor(getClient) {
|
|
987
|
+
__publicField(this, "getClient", getClient);
|
|
945
988
|
}
|
|
946
989
|
/**
|
|
947
|
-
*
|
|
948
|
-
* `null` when there is no draft pending. Draft reads do NOT fall
|
|
949
|
-
* back to the published overlay or the artifact registry — a `null`
|
|
950
|
-
* unambiguously means "nothing to publish".
|
|
951
|
-
*
|
|
952
|
-
* Note: the framework wraps draft responses in an envelope
|
|
953
|
-
* `{ type, name, item }` (matching `getMetaItem`); callers should
|
|
954
|
-
* read `.item` to get the body. The legacy `get()` returns the
|
|
955
|
-
* unwrapped body, so this method preserves that asymmetry by
|
|
956
|
-
* returning whatever the server sent.
|
|
990
|
+
* Deploy an application to the cloud.
|
|
957
991
|
*/
|
|
958
|
-
async
|
|
959
|
-
|
|
992
|
+
async deploy(appId, config) {
|
|
993
|
+
const client = this.getClient();
|
|
994
|
+
const result = await client.cloud?.deploy?.(appId, config);
|
|
995
|
+
return result ?? { deploymentId: `deploy-${Date.now()}`, status: "pending" };
|
|
960
996
|
}
|
|
961
997
|
/**
|
|
962
|
-
*
|
|
963
|
-
* item payload and the `{ item: ... }` / `{ metadata: ... }`
|
|
964
|
-
* envelopes; we send bare for consistency. Pass `mode: 'draft'` to
|
|
965
|
-
* stage the change without publishing (Studio's "Save" button).
|
|
998
|
+
* Get deployment status.
|
|
966
999
|
*/
|
|
967
|
-
async
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
|
|
972
|
-
const qs = params.length ? `?${params.join("&")}` : "";
|
|
973
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
974
|
-
const headers = {
|
|
975
|
-
...this.headers,
|
|
976
|
-
"Content-Type": "application/json"
|
|
977
|
-
};
|
|
978
|
-
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
979
|
-
if (options.actor) headers["X-Actor"] = options.actor;
|
|
980
|
-
const res = await this.fetchImpl(url, {
|
|
981
|
-
method: "PUT",
|
|
982
|
-
headers,
|
|
983
|
-
body: JSON.stringify(item)
|
|
984
|
-
});
|
|
985
|
-
if (!res.ok) throw await parseError(res);
|
|
986
|
-
return await res.json();
|
|
1000
|
+
async getDeploymentStatus(deploymentId) {
|
|
1001
|
+
const client = this.getClient();
|
|
1002
|
+
const result = await client.cloud?.getDeployment?.(deploymentId);
|
|
1003
|
+
return result ?? { status: "unknown" };
|
|
987
1004
|
}
|
|
988
1005
|
/**
|
|
989
|
-
*
|
|
990
|
-
* `(type, name)` into the active overlay and drops the draft. Works for ANY
|
|
991
|
-
* draft, including ones with no `packageId` binding (which the package-scoped
|
|
992
|
-
* `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
|
|
993
|
-
* exact set returned by {@link listDrafts} without needing a package.
|
|
994
|
-
*
|
|
995
|
-
* Returns the server result. For `seed` drafts the protocol also materializes
|
|
996
|
-
* the rows and reports under `seedApplied` — a data problem never fails the
|
|
997
|
-
* publish, so callers should check `seedApplied?.success` and warn the user
|
|
998
|
-
* rather than assume the data went live.
|
|
1006
|
+
* Search marketplace entries.
|
|
999
1007
|
*/
|
|
1000
|
-
async
|
|
1001
|
-
const
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1004
|
-
headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
|
|
1005
|
-
body: "{}"
|
|
1006
|
-
});
|
|
1007
|
-
if (!res.ok) throw await parseError(res);
|
|
1008
|
-
const body = await res.json().catch(() => ({}));
|
|
1009
|
-
const inner = body?.data && typeof body.data === "object" ? body.data : body;
|
|
1010
|
-
return inner;
|
|
1008
|
+
async searchMarketplace(query, category) {
|
|
1009
|
+
const client = this.getClient();
|
|
1010
|
+
const result = await client.cloud?.marketplace?.search?.({ query, category });
|
|
1011
|
+
return result?.items ?? [];
|
|
1011
1012
|
}
|
|
1012
1013
|
/**
|
|
1013
|
-
*
|
|
1014
|
-
* `code` (the artifact / fallback default), `overlay` (the saved
|
|
1015
|
-
* customisation, if any), and `effective` (what the runtime sees).
|
|
1014
|
+
* Install a marketplace plugin.
|
|
1016
1015
|
*/
|
|
1017
|
-
async
|
|
1018
|
-
const
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1016
|
+
async installPlugin(pluginId) {
|
|
1017
|
+
const client = this.getClient();
|
|
1018
|
+
const result = await client.cloud?.marketplace?.install?.(pluginId);
|
|
1019
|
+
return result ?? { success: false };
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
// src/contracts.ts
|
|
1024
|
+
function validatePluginContract(contract) {
|
|
1025
|
+
const errors = [];
|
|
1026
|
+
const warnings = [];
|
|
1027
|
+
if (!contract.name || contract.name.trim().length === 0) {
|
|
1028
|
+
errors.push({ field: "name", message: "Plugin name is required", code: "MISSING_NAME" });
|
|
1029
|
+
}
|
|
1030
|
+
if (!contract.version || !/^\d+\.\d+\.\d+/.test(contract.version)) {
|
|
1031
|
+
errors.push({ field: "version", message: "Valid semver version is required", code: "INVALID_VERSION" });
|
|
1032
|
+
}
|
|
1033
|
+
if (!contract.exports || contract.exports.length === 0) {
|
|
1034
|
+
errors.push({ field: "exports", message: "At least one export is required", code: "NO_EXPORTS" });
|
|
1035
|
+
}
|
|
1036
|
+
if (contract.exports) {
|
|
1037
|
+
const validTypes = ["component", "hook", "utility", "provider"];
|
|
1038
|
+
for (const exp of contract.exports) {
|
|
1039
|
+
if (!exp.name) {
|
|
1040
|
+
errors.push({ field: "exports.name", message: "Export name is required", code: "MISSING_EXPORT_NAME" });
|
|
1041
|
+
}
|
|
1042
|
+
if (!validTypes.includes(exp.type)) {
|
|
1043
|
+
errors.push({ field: "exports.type", message: `Invalid export type: ${exp.type}`, code: "INVALID_EXPORT_TYPE" });
|
|
1044
|
+
}
|
|
1034
1045
|
}
|
|
1035
|
-
return {
|
|
1036
|
-
code: body.code ?? null,
|
|
1037
|
-
overlay: body.overlay ?? null,
|
|
1038
|
-
overlayScope: body.overlayScope ?? null,
|
|
1039
|
-
effective: body.effective ?? null,
|
|
1040
|
-
...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
|
|
1041
|
-
// ADR-0010 Phase 4 — pass through the metadata-protection envelope so
|
|
1042
|
-
// the editor can render lock affordances without a second round trip.
|
|
1043
|
-
...body.lock !== void 0 ? { lock: body.lock } : {},
|
|
1044
|
-
...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
|
|
1045
|
-
...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
|
|
1046
|
-
...body.lockDocsUrl !== void 0 ? { lockDocsUrl: body.lockDocsUrl } : {},
|
|
1047
|
-
...body.provenance !== void 0 ? { provenance: body.provenance } : {},
|
|
1048
|
-
...body.packageId !== void 0 ? { packageId: body.packageId } : {},
|
|
1049
|
-
...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
|
|
1050
|
-
...body.editable !== void 0 ? { editable: body.editable } : {},
|
|
1051
|
-
...body.deletable !== void 0 ? { deletable: body.deletable } : {},
|
|
1052
|
-
...body.resettable !== void 0 ? { resettable: body.resettable } : {}
|
|
1053
|
-
};
|
|
1054
1046
|
}
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1047
|
+
if (!contract.permissions || contract.permissions.length === 0) {
|
|
1048
|
+
warnings.push("No permissions declared \u2014 plugin will have minimal access");
|
|
1049
|
+
}
|
|
1050
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
1051
|
+
}
|
|
1052
|
+
function generateContractManifest(contract) {
|
|
1053
|
+
return {
|
|
1054
|
+
$schema: "https://objectui.org/schemas/plugin-contract-v1.json",
|
|
1055
|
+
name: contract.name,
|
|
1056
|
+
version: contract.version,
|
|
1057
|
+
peerDependencies: contract.peerDependencies ?? {},
|
|
1058
|
+
exports: contract.exports.map((exp) => ({
|
|
1059
|
+
name: exp.name,
|
|
1060
|
+
type: exp.type,
|
|
1061
|
+
description: exp.description ?? ""
|
|
1062
|
+
})),
|
|
1063
|
+
permissions: contract.permissions ?? [],
|
|
1064
|
+
api: contract.api ?? {},
|
|
1065
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// src/userState.ts
|
|
1070
|
+
var DEFAULT_RESOURCE = "sys_user_preference";
|
|
1071
|
+
function createObjectStackUserStateAdapter(options) {
|
|
1072
|
+
const {
|
|
1073
|
+
dataSource,
|
|
1074
|
+
userId,
|
|
1075
|
+
key,
|
|
1076
|
+
resource = DEFAULT_RESOURCE,
|
|
1077
|
+
onError = () => {
|
|
1074
1078
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1079
|
+
} = options;
|
|
1080
|
+
let cachedRowId = null;
|
|
1081
|
+
let saveChain = Promise.resolve();
|
|
1082
|
+
const findExisting = async () => {
|
|
1083
|
+
const params = {
|
|
1084
|
+
$filter: {
|
|
1085
|
+
user_id: userId,
|
|
1086
|
+
key
|
|
1087
|
+
},
|
|
1088
|
+
$top: 1
|
|
1083
1089
|
};
|
|
1090
|
+
const result = await dataSource.find(resource, params);
|
|
1091
|
+
const rows = result?.data ?? [];
|
|
1092
|
+
const match = rows.find((r) => {
|
|
1093
|
+
if (!r) return false;
|
|
1094
|
+
const userMismatch = r.user_id !== void 0 && r.user_id !== userId;
|
|
1095
|
+
const keyMismatch = r.key !== void 0 && r.key !== key;
|
|
1096
|
+
return !userMismatch && !keyMismatch;
|
|
1097
|
+
});
|
|
1098
|
+
return match ?? null;
|
|
1099
|
+
};
|
|
1100
|
+
const decodeValue = (value) => {
|
|
1101
|
+
if (Array.isArray(value)) return value;
|
|
1102
|
+
if (typeof value === "string") {
|
|
1103
|
+
try {
|
|
1104
|
+
const parsed = JSON.parse(value);
|
|
1105
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1106
|
+
} catch {
|
|
1107
|
+
return [];
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return [];
|
|
1111
|
+
};
|
|
1112
|
+
const upsert = async (items) => {
|
|
1113
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1114
|
+
if (cachedRowId !== null) {
|
|
1115
|
+
try {
|
|
1116
|
+
await dataSource.update(resource, cachedRowId, { value: items, updated_at: now });
|
|
1117
|
+
return;
|
|
1118
|
+
} catch (updateError) {
|
|
1119
|
+
cachedRowId = null;
|
|
1120
|
+
onError("save", updateError);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
const existing = await findExisting();
|
|
1124
|
+
if (existing && existing.id !== void 0 && existing.id !== null) {
|
|
1125
|
+
cachedRowId = existing.id;
|
|
1126
|
+
await dataSource.update(resource, existing.id, { value: items, updated_at: now });
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
try {
|
|
1130
|
+
const created = await dataSource.create(resource, {
|
|
1131
|
+
user_id: userId,
|
|
1132
|
+
key,
|
|
1133
|
+
value: items,
|
|
1134
|
+
updated_at: now
|
|
1135
|
+
});
|
|
1136
|
+
const newId = created?.id;
|
|
1137
|
+
if (newId !== void 0 && newId !== null) cachedRowId = newId;
|
|
1138
|
+
} catch (createError) {
|
|
1139
|
+
const recovered = await findExisting();
|
|
1140
|
+
if (recovered && recovered.id !== void 0 && recovered.id !== null) {
|
|
1141
|
+
cachedRowId = recovered.id;
|
|
1142
|
+
await dataSource.update(resource, recovered.id, { value: items, updated_at: now });
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
throw createError;
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
return {
|
|
1149
|
+
async load() {
|
|
1150
|
+
try {
|
|
1151
|
+
const row = await findExisting();
|
|
1152
|
+
if (!row) {
|
|
1153
|
+
cachedRowId = null;
|
|
1154
|
+
return [];
|
|
1155
|
+
}
|
|
1156
|
+
if (row.id !== void 0 && row.id !== null) cachedRowId = row.id;
|
|
1157
|
+
return decodeValue(row.value);
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
onError("load", error);
|
|
1160
|
+
return [];
|
|
1161
|
+
}
|
|
1162
|
+
},
|
|
1163
|
+
async save(items) {
|
|
1164
|
+
const run = saveChain.then(() => upsert(items)).catch((error) => {
|
|
1165
|
+
onError("save", error);
|
|
1166
|
+
});
|
|
1167
|
+
saveChain = run;
|
|
1168
|
+
return run;
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// src/integration.ts
|
|
1174
|
+
var IntegrationManager = class {
|
|
1175
|
+
constructor() {
|
|
1176
|
+
__publicField(this, "integrations", /* @__PURE__ */ new Map());
|
|
1084
1177
|
}
|
|
1085
1178
|
/**
|
|
1086
|
-
*
|
|
1087
|
-
* for pre-delete impact analysis: "Are any views pointing at this
|
|
1088
|
-
* object before I drop it?".
|
|
1179
|
+
* Register a new integration.
|
|
1089
1180
|
*/
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
1093
|
-
if (res.status === 404) return [];
|
|
1094
|
-
if (!res.ok) throw await parseError(res);
|
|
1095
|
-
const data = await res.json();
|
|
1096
|
-
return Array.isArray(data) ? data : data?.items ?? [];
|
|
1181
|
+
register(id, config) {
|
|
1182
|
+
this.integrations.set(id, config);
|
|
1097
1183
|
}
|
|
1098
1184
|
/**
|
|
1099
|
-
*
|
|
1100
|
-
* Idempotent: returns the result even when no overlay row existed.
|
|
1101
|
-
* Pass `state: 'draft'` to discard the pending draft only (keeps the
|
|
1102
|
-
* published overlay intact) — useful for a Studio "Discard draft" button.
|
|
1185
|
+
* Remove an integration.
|
|
1103
1186
|
*/
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
|
|
1107
|
-
const headers = { ...this.headers };
|
|
1108
|
-
if (options.ifMatch) headers["If-Match"] = options.ifMatch;
|
|
1109
|
-
if (options.actor) headers["X-Actor"] = options.actor;
|
|
1110
|
-
const res = await this.fetchImpl(url, { method: "DELETE", headers });
|
|
1111
|
-
if (!res.ok) throw await parseError(res);
|
|
1112
|
-
return await res.json();
|
|
1187
|
+
unregister(id) {
|
|
1188
|
+
this.integrations.delete(id);
|
|
1113
1189
|
}
|
|
1114
1190
|
/**
|
|
1115
|
-
*
|
|
1116
|
-
* (POST `/meta/:type/:name/publish`). Returns
|
|
1117
|
-
* `{ success, version, seq, message }`. Throws a `404 no_draft` if
|
|
1118
|
-
* nothing is pending and `409 metadata_conflict` if the published
|
|
1119
|
-
* overlay moved while the draft was sitting.
|
|
1191
|
+
* Get all registered integrations.
|
|
1120
1192
|
*/
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
const headers = {
|
|
1124
|
-
...this.headers,
|
|
1125
|
-
"Content-Type": "application/json"
|
|
1126
|
-
};
|
|
1127
|
-
if (options.actor) headers["X-Actor"] = options.actor;
|
|
1128
|
-
const res = await this.fetchImpl(url, {
|
|
1129
|
-
method: "POST",
|
|
1130
|
-
headers,
|
|
1131
|
-
body: JSON.stringify(options.message ? { message: options.message } : {})
|
|
1132
|
-
});
|
|
1133
|
-
if (!res.ok) throw await parseError(res);
|
|
1134
|
-
return await res.json();
|
|
1193
|
+
getAll() {
|
|
1194
|
+
return new Map(this.integrations);
|
|
1135
1195
|
}
|
|
1136
1196
|
/**
|
|
1137
|
-
*
|
|
1138
|
-
* The server reads the history row at `toVersion`, writes its body back
|
|
1139
|
-
* as the active overlay with `operation_type='revert'`. Returns
|
|
1140
|
-
* `{ success, version, seq, restoredFromVersion, message }`. Throws
|
|
1141
|
-
* `404 version_not_found` for unknown versions and `409 version_not_restorable`
|
|
1142
|
-
* when the target version is a delete tombstone.
|
|
1197
|
+
* Get integrations that match a specific event.
|
|
1143
1198
|
*/
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
"Content-Type": "application/json"
|
|
1149
|
-
};
|
|
1150
|
-
if (options.actor) headers["X-Actor"] = options.actor;
|
|
1151
|
-
const res = await this.fetchImpl(url, {
|
|
1152
|
-
method: "POST",
|
|
1153
|
-
headers,
|
|
1154
|
-
body: JSON.stringify({
|
|
1155
|
-
toVersion,
|
|
1156
|
-
...options.message ? { message: options.message } : {}
|
|
1157
|
-
})
|
|
1158
|
-
});
|
|
1159
|
-
if (!res.ok) throw await parseError(res);
|
|
1160
|
-
return await res.json();
|
|
1199
|
+
getForEvent(event) {
|
|
1200
|
+
return Array.from(this.integrations.values()).filter(
|
|
1201
|
+
(integration) => integration.enabled && integration.triggers?.some((t) => t.event === event)
|
|
1202
|
+
);
|
|
1161
1203
|
}
|
|
1162
1204
|
/**
|
|
1163
|
-
*
|
|
1164
|
-
*
|
|
1165
|
-
* `{ added, removed, changed }` where each entry carries a `path` and
|
|
1166
|
-
* the relevant value(s). Useful for "what changed in this draft?" and
|
|
1167
|
-
* pre-rollback previews.
|
|
1205
|
+
* Dispatch an event to all matching integrations.
|
|
1206
|
+
* Returns results for each integration.
|
|
1168
1207
|
*/
|
|
1169
|
-
async
|
|
1170
|
-
const
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
return
|
|
1182
|
-
}
|
|
1183
|
-
/** Fetch the durable history (change log) for one metadata item. */
|
|
1184
|
-
async history(type, name, options = {}) {
|
|
1185
|
-
const params = new URLSearchParams();
|
|
1186
|
-
if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
|
|
1187
|
-
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
1188
|
-
const qs = params.toString();
|
|
1189
|
-
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
|
|
1190
|
-
const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
|
|
1191
|
-
if (!res.ok) throw await parseError(res);
|
|
1192
|
-
return await res.json();
|
|
1208
|
+
async dispatch(event, payload) {
|
|
1209
|
+
const matching = this.getForEvent(event);
|
|
1210
|
+
const results = [];
|
|
1211
|
+
for (const [id, integration] of this.integrations) {
|
|
1212
|
+
if (!matching.includes(integration)) continue;
|
|
1213
|
+
try {
|
|
1214
|
+
await this.send(integration, payload);
|
|
1215
|
+
results.push({ id, success: true });
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
results.push({ id, success: false, error: err.message });
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
return results;
|
|
1193
1221
|
}
|
|
1194
1222
|
/**
|
|
1195
|
-
*
|
|
1196
|
-
* (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
|
|
1197
|
-
* delete/reset attempt — allowed, denied, or forced — so the
|
|
1198
|
-
* Studio "审计日志 / Audit log" tab can render who tried what
|
|
1199
|
-
* and whether a lock blocked it.
|
|
1223
|
+
* Send payload to a specific integration.
|
|
1200
1224
|
*/
|
|
1201
|
-
async
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1225
|
+
async send(integration, payload) {
|
|
1226
|
+
switch (integration.provider) {
|
|
1227
|
+
case "webhook": {
|
|
1228
|
+
const cfg = integration.config;
|
|
1229
|
+
const url = cfg.url;
|
|
1230
|
+
try {
|
|
1231
|
+
const parsed = new URL(url);
|
|
1232
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1233
|
+
throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
|
|
1234
|
+
}
|
|
1235
|
+
} catch (e) {
|
|
1236
|
+
if (e instanceof TypeError) {
|
|
1237
|
+
throw new Error(`Invalid webhook URL: ${url}`);
|
|
1238
|
+
}
|
|
1239
|
+
throw e;
|
|
1240
|
+
}
|
|
1241
|
+
await fetch(url, {
|
|
1242
|
+
method: cfg.method,
|
|
1243
|
+
headers: {
|
|
1244
|
+
"Content-Type": "application/json",
|
|
1245
|
+
...cfg.headers
|
|
1246
|
+
},
|
|
1247
|
+
body: JSON.stringify(payload)
|
|
1248
|
+
});
|
|
1249
|
+
break;
|
|
1250
|
+
}
|
|
1251
|
+
case "slack": {
|
|
1252
|
+
const cfg = integration.config;
|
|
1253
|
+
const url = cfg.webhookUrl;
|
|
1254
|
+
try {
|
|
1255
|
+
const parsed = new URL(url);
|
|
1256
|
+
if (parsed.protocol !== "https:") {
|
|
1257
|
+
throw new Error(`Invalid Slack webhook URL protocol: ${parsed.protocol}`);
|
|
1258
|
+
}
|
|
1259
|
+
} catch (e) {
|
|
1260
|
+
if (e instanceof TypeError) {
|
|
1261
|
+
throw new Error(`Invalid Slack webhook URL: ${url}`);
|
|
1262
|
+
}
|
|
1263
|
+
throw e;
|
|
1264
|
+
}
|
|
1265
|
+
await fetch(url, {
|
|
1266
|
+
method: "POST",
|
|
1267
|
+
headers: { "Content-Type": "application/json" },
|
|
1268
|
+
body: JSON.stringify({
|
|
1269
|
+
channel: cfg.channel,
|
|
1270
|
+
username: cfg.username,
|
|
1271
|
+
icon_emoji: cfg.iconEmoji,
|
|
1272
|
+
text: JSON.stringify(payload)
|
|
1273
|
+
})
|
|
1274
|
+
});
|
|
1275
|
+
break;
|
|
1276
|
+
}
|
|
1277
|
+
// Email and other providers would require server-side implementation
|
|
1278
|
+
default:
|
|
1279
|
+
break;
|
|
1280
|
+
}
|
|
1209
1281
|
}
|
|
1210
1282
|
};
|
|
1211
1283
|
|
|
@@ -1446,6 +1518,16 @@ async function getSharedDiscovery(baseUrl, fetcher) {
|
|
|
1446
1518
|
function clearSharedDiscoveryCache() {
|
|
1447
1519
|
discoveryCache.clear();
|
|
1448
1520
|
}
|
|
1521
|
+
function readTransactionalBatchCapability(discovery) {
|
|
1522
|
+
const caps = discovery?.capabilities;
|
|
1523
|
+
if (!caps || typeof caps !== "object") return void 0;
|
|
1524
|
+
const value = caps.transactionalBatch;
|
|
1525
|
+
if (typeof value === "boolean") return value;
|
|
1526
|
+
if (value && typeof value === "object" && typeof value.enabled === "boolean") {
|
|
1527
|
+
return value.enabled;
|
|
1528
|
+
}
|
|
1529
|
+
return void 0;
|
|
1530
|
+
}
|
|
1449
1531
|
function is404Error(error) {
|
|
1450
1532
|
if (!error || typeof error !== "object") return false;
|
|
1451
1533
|
const err = error;
|
|
@@ -1546,6 +1628,21 @@ var ObjectStackAdapter = class {
|
|
|
1546
1628
|
// so optional collections like sys_presence don't hammer the server with
|
|
1547
1629
|
// failing requests on every record open / panel render.
|
|
1548
1630
|
__publicField(this, "missingResources", /* @__PURE__ */ new Set());
|
|
1631
|
+
// Set once the server has told us it can't do a cross-object transactional
|
|
1632
|
+
// batch (the client SDK's data.batchTransaction threw HTTP 404/405/501). After
|
|
1633
|
+
// that, batchTransaction skips the SDK call and serves every call via the
|
|
1634
|
+
// client-side emulation — the non-atomic fallback lives HERE, isolated to the
|
|
1635
|
+
// one adapter that has to cope with a backend lacking server atomicity (#2679).
|
|
1636
|
+
__publicField(this, "batchUnsupported", false);
|
|
1637
|
+
// The server's declared cross-object atomic-batch capability, read from
|
|
1638
|
+
// discovery at connect() (framework #3298 / objectui #2693). `true` → the
|
|
1639
|
+
// backend GUARANTEES an atomic `/batch`, so batchTransaction trusts it and
|
|
1640
|
+
// never degrades to the non-atomic emulation (any failure surfaces as a real
|
|
1641
|
+
// error). `false` or `undefined` (capability absent → backend predates #3298)
|
|
1642
|
+
// → keep the legacy runtime-probe + emulation fallback so a save is still
|
|
1643
|
+
// possible; dropping it there would turn "saves, less safe" into "no save
|
|
1644
|
+
// path" on older backends (#2679 compatibility constraint).
|
|
1645
|
+
__publicField(this, "atomicBatchCapability");
|
|
1549
1646
|
// Subscribers registered via onMutation(). Emitted after each successful
|
|
1550
1647
|
// create/update/delete so data-bound views (ListView, ObjectView, kanban,
|
|
1551
1648
|
// calendar) auto-refresh — the interface ListView relies on to reflect
|
|
@@ -1584,6 +1681,7 @@ var ObjectStackAdapter = class {
|
|
|
1584
1681
|
return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
|
|
1585
1682
|
});
|
|
1586
1683
|
this.client.discoveryInfo = data;
|
|
1684
|
+
this.atomicBatchCapability = readTransactionalBatchCapability(data);
|
|
1587
1685
|
this.connected = true;
|
|
1588
1686
|
this.reconnectAttempts = 0;
|
|
1589
1687
|
this.setConnectionState("connected");
|
|
@@ -1933,33 +2031,84 @@ var ObjectStackAdapter = class {
|
|
|
1933
2031
|
* @returns Promise resolving to array of results
|
|
1934
2032
|
*/
|
|
1935
2033
|
/**
|
|
1936
|
-
* Cross-object transactional batch (ObjectStack #1604
|
|
1937
|
-
* in ONE server transaction — commit all or roll back
|
|
1938
|
-
* `{ $ref: <earlier op index> }` resolves to that op's
|
|
1939
|
-
* can reference its parent created earlier in the same
|
|
2034
|
+
* Cross-object transactional batch (ObjectStack #1604 / ADR-0034 item 4).
|
|
2035
|
+
* Runs the operations in ONE server transaction — commit all or roll back
|
|
2036
|
+
* all. A field value of `{ $ref: <earlier op index> }` resolves to that op's
|
|
2037
|
+
* created id, so a child can reference its parent created earlier in the same
|
|
2038
|
+
* batch (master-detail).
|
|
2039
|
+
*
|
|
2040
|
+
* Transport: the published `@objectstack/client` SDK method
|
|
2041
|
+
* `data.batchTransaction` (framework #3271; shipped since client v16, our
|
|
2042
|
+
* dependency floor). Per AGENTS.md §7 data always flows through the client —
|
|
2043
|
+
* never a hand-rolled `fetch('/api/v1/batch')`.
|
|
2044
|
+
*
|
|
2045
|
+
* Fallback decision — declarative capability negotiation (framework #3298 /
|
|
2046
|
+
* objectui #2693). At connect() we read `capabilities.transactionalBatch`
|
|
2047
|
+
* from discovery:
|
|
2048
|
+
* - Declared `true` → the backend GUARANTEES atomicity (declared ===
|
|
2049
|
+
* enforced). We TRUST it: any batch failure — including 404/405/501 —
|
|
2050
|
+
* surfaces as a real error. No non-atomic client-side compensation. This
|
|
2051
|
+
* is the path modern backends take.
|
|
2052
|
+
* - Declared `false`, or ABSENT (backend predates #3298) → we can't rely on
|
|
2053
|
+
* server atomicity, so we keep the legacy behaviour: on 404/405 (no
|
|
2054
|
+
* endpoint) or 501 (runtime without transactions) degrade to the
|
|
2055
|
+
* client-side, NON-atomic {@link emulateBatchTransaction} so a save is
|
|
2056
|
+
* still possible. Removing that here would regress older backends from
|
|
2057
|
+
* "saves, less safe" to "no save path" (#2679 compatibility constraint).
|
|
2058
|
+
* The non-atomic fallback stays isolated to THIS adapter.
|
|
1940
2059
|
*/
|
|
1941
2060
|
async batchTransaction(operations) {
|
|
1942
|
-
|
|
1943
|
-
const
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
2061
|
+
await this.connect();
|
|
2062
|
+
const guaranteed = this.atomicBatchCapability === true;
|
|
2063
|
+
if (!guaranteed && this.batchUnsupported) {
|
|
2064
|
+
return emulateBatchTransaction(this, operations);
|
|
2065
|
+
}
|
|
2066
|
+
try {
|
|
2067
|
+
const payload = await this.client.data.batchTransaction(operations);
|
|
2068
|
+
this.emitBatchMutations(operations, payload?.results);
|
|
2069
|
+
return payload;
|
|
2070
|
+
} catch (err) {
|
|
2071
|
+
const status = this.errorStatusOf(err);
|
|
2072
|
+
if (!guaranteed && this.batchStatusUnsupported(status)) {
|
|
2073
|
+
return this.fallbackToEmulation(operations, status);
|
|
2074
|
+
}
|
|
2075
|
+
throw err;
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
/** True for statuses that mean "this backend can't do a transactional batch". */
|
|
2079
|
+
batchStatusUnsupported(status) {
|
|
2080
|
+
return status === 404 || status === 405 || status === 501;
|
|
2081
|
+
}
|
|
2082
|
+
/** Best-effort HTTP status extraction from a thrown SDK/client error. */
|
|
2083
|
+
errorStatusOf(err) {
|
|
2084
|
+
if (!err || typeof err !== "object") return void 0;
|
|
2085
|
+
const e = err;
|
|
2086
|
+
const s = e.httpStatus ?? e.status ?? e.statusCode;
|
|
2087
|
+
return typeof s === "number" ? s : void 0;
|
|
2088
|
+
}
|
|
2089
|
+
/** Mark the endpoint unsupported (warn once) and serve via emulation. */
|
|
2090
|
+
fallbackToEmulation(operations, status) {
|
|
2091
|
+
if (!this.batchUnsupported) {
|
|
2092
|
+
this.batchUnsupported = true;
|
|
2093
|
+
console.warn(
|
|
2094
|
+
`ObjectStackAdapter: POST /api/v1/batch unavailable (HTTP ${status ?? "?"}) \u2014 falling back to non-atomic client-side batch emulation. Cross-object saves on this backend are best-effort, not transactional.`
|
|
1959
2095
|
);
|
|
1960
2096
|
}
|
|
1961
|
-
|
|
1962
|
-
|
|
2097
|
+
return emulateBatchTransaction(this, operations);
|
|
2098
|
+
}
|
|
2099
|
+
/**
|
|
2100
|
+
* Emit one MutationEvent per committed operation so the invalidation bus
|
|
2101
|
+
* (#2269) sees writes that went through /batch exactly like single
|
|
2102
|
+
* create/update/delete calls — master-detail ModalForm saves otherwise leave
|
|
2103
|
+
* related lists and count badges stale (#2582). `results` is index-aligned
|
|
2104
|
+
* with `operations`; creates take id/record from the server echo.
|
|
2105
|
+
*
|
|
2106
|
+
* Only called on the server-committed paths. The emulation branch drives the
|
|
2107
|
+
* adapter's own create/update/delete primitives, which already emit — so it
|
|
2108
|
+
* must NOT be routed through here, or events would double-fire.
|
|
2109
|
+
*/
|
|
2110
|
+
emitBatchMutations(operations, rawResults) {
|
|
2111
|
+
const results = Array.isArray(rawResults) ? rawResults : [];
|
|
1963
2112
|
operations.forEach((op, i) => {
|
|
1964
2113
|
const action = op.action ?? "create";
|
|
1965
2114
|
const echo = results[i];
|
|
@@ -1971,7 +2120,6 @@ var ObjectStackAdapter = class {
|
|
|
1971
2120
|
this.emitMutation({ type: "delete", resource: op.object, id: op.id });
|
|
1972
2121
|
}
|
|
1973
2122
|
});
|
|
1974
|
-
return payload;
|
|
1975
2123
|
}
|
|
1976
2124
|
async bulk(resource, operation, data) {
|
|
1977
2125
|
await this.connect();
|
|
@@ -2692,22 +2840,18 @@ var ObjectStackAdapter = class {
|
|
|
2692
2840
|
*/
|
|
2693
2841
|
async updateViewConfig(objectName, viewId, config) {
|
|
2694
2842
|
await this.connect();
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
return result ?? void 0;
|
|
2708
|
-
} catch (err) {
|
|
2709
|
-
throw err;
|
|
2710
|
-
}
|
|
2843
|
+
const merged = { ...config || {}, object: config?.object || objectName, name: viewId };
|
|
2844
|
+
const result = await this.client.meta.saveItem(
|
|
2845
|
+
"view",
|
|
2846
|
+
viewId,
|
|
2847
|
+
merged
|
|
2848
|
+
);
|
|
2849
|
+
const cacheKey = `view:${objectName}:${viewId}`;
|
|
2850
|
+
this.metadataCache.invalidate?.(cacheKey);
|
|
2851
|
+
this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
|
|
2852
|
+
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
2853
|
+
if (result && result.item) return result.item;
|
|
2854
|
+
return result ?? void 0;
|
|
2711
2855
|
}
|
|
2712
2856
|
/**
|
|
2713
2857
|
* List user-created views for a given object via the metadata overlay
|
|
@@ -2719,11 +2863,17 @@ var ObjectStackAdapter = class {
|
|
|
2719
2863
|
* Filters by `data.object === objectName` (or top-level `object`)
|
|
2720
2864
|
* client-side because the metadata index is name-only, not field-typed.
|
|
2721
2865
|
*/
|
|
2722
|
-
async listViews(objectName) {
|
|
2866
|
+
async listViews(objectName, options) {
|
|
2723
2867
|
await this.connect();
|
|
2724
2868
|
try {
|
|
2725
|
-
|
|
2726
|
-
|
|
2869
|
+
let items;
|
|
2870
|
+
if (options?.previewDrafts) {
|
|
2871
|
+
const draftItems = await this.metadataClient().withPreviewDrafts(true).list("view");
|
|
2872
|
+
items = Array.isArray(draftItems) ? draftItems : [];
|
|
2873
|
+
} else {
|
|
2874
|
+
const result = await this.client.meta.getItems("view");
|
|
2875
|
+
items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
|
|
2876
|
+
}
|
|
2727
2877
|
const FORM_FAMILY = /* @__PURE__ */ new Set(["form", "detail"]);
|
|
2728
2878
|
return items.filter((v) => {
|
|
2729
2879
|
if (!v) return false;
|
|
@@ -2734,21 +2884,36 @@ var ObjectStackAdapter = class {
|
|
|
2734
2884
|
return !(viewKind && FORM_FAMILY.has(viewKind));
|
|
2735
2885
|
}).map((v) => {
|
|
2736
2886
|
const spec = v.list ?? v;
|
|
2887
|
+
const isDraft = v._draft === true || spec?._draft === true;
|
|
2737
2888
|
if (spec && spec.config && typeof spec.config === "object") {
|
|
2738
2889
|
return {
|
|
2739
2890
|
...spec.config,
|
|
2740
2891
|
name: spec.name ?? spec.config.name,
|
|
2741
2892
|
label: spec.label ?? spec.config.label,
|
|
2742
|
-
isDefault: !!spec.isDefault
|
|
2893
|
+
isDefault: !!spec.isDefault,
|
|
2894
|
+
...isDraft ? { _draft: true } : {}
|
|
2743
2895
|
};
|
|
2744
2896
|
}
|
|
2745
|
-
return spec;
|
|
2897
|
+
return isDraft ? { ...spec, _draft: true } : spec;
|
|
2746
2898
|
});
|
|
2747
2899
|
} catch (err) {
|
|
2748
2900
|
console.warn("[OBJECTSTACKDataSource] listViews failed:", err);
|
|
2749
2901
|
return [];
|
|
2750
2902
|
}
|
|
2751
2903
|
}
|
|
2904
|
+
/**
|
|
2905
|
+
* Build a {@link MetadataClient} bound to this adapter's server + auth. Used
|
|
2906
|
+
* by draft-aware reads (`listViews({ previewDrafts })`) so the `/meta` route,
|
|
2907
|
+
* `?preview=draft` flag, and environment scoping live in the SDK rather than
|
|
2908
|
+
* being hand-assembled at each call site (#2767 P3).
|
|
2909
|
+
*/
|
|
2910
|
+
metadataClient() {
|
|
2911
|
+
return new MetadataClient({
|
|
2912
|
+
baseUrl: this.baseUrl,
|
|
2913
|
+
fetch: this.fetchImpl,
|
|
2914
|
+
...this.token ? { headers: { Authorization: `Bearer ${this.token}` } } : {}
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
2752
2917
|
/**
|
|
2753
2918
|
* List registered import `mapping` artifacts targeting a given object
|
|
2754
2919
|
* (framework #2611). Reads the `mapping` metadata kind via the overlay API
|
|
@@ -2793,14 +2958,10 @@ var ObjectStackAdapter = class {
|
|
|
2793
2958
|
object: spec?.object || objectName,
|
|
2794
2959
|
data: spec?.data || { provider: "object", object: objectName }
|
|
2795
2960
|
};
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
return fullSpec;
|
|
2801
|
-
} catch (err) {
|
|
2802
|
-
throw err;
|
|
2803
|
-
}
|
|
2961
|
+
const result = await this.client.meta.saveItem("view", name, fullSpec);
|
|
2962
|
+
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
2963
|
+
if (result && result.item) return result.item;
|
|
2964
|
+
return fullSpec;
|
|
2804
2965
|
}
|
|
2805
2966
|
/**
|
|
2806
2967
|
* Apply a partial update to an existing overlay view. Reads the current
|
|
@@ -2823,15 +2984,11 @@ var ObjectStackAdapter = class {
|
|
|
2823
2984
|
name: viewName,
|
|
2824
2985
|
object: current?.object || current?.data?.object || objectName
|
|
2825
2986
|
};
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
return merged;
|
|
2832
|
-
} catch (err) {
|
|
2833
|
-
throw err;
|
|
2834
|
-
}
|
|
2987
|
+
const result = await this.client.meta.saveItem("view", viewName, merged);
|
|
2988
|
+
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
2989
|
+
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
|
|
2990
|
+
if (result && result.item) return result.item;
|
|
2991
|
+
return merged;
|
|
2835
2992
|
}
|
|
2836
2993
|
/**
|
|
2837
2994
|
* Delete an overlay view (reset to artifact default if one exists, or
|
|
@@ -2840,14 +2997,10 @@ var ObjectStackAdapter = class {
|
|
|
2840
2997
|
*/
|
|
2841
2998
|
async deleteView(objectName, viewName) {
|
|
2842
2999
|
await this.connect();
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
return { deleted: !!(result?.deleted ?? result?.reset ?? true) };
|
|
2848
|
-
} catch (err) {
|
|
2849
|
-
throw err;
|
|
2850
|
-
}
|
|
3000
|
+
const result = await this.client.meta.deleteItem("view", viewName);
|
|
3001
|
+
this.metadataCache.invalidate?.(`views:${objectName}`);
|
|
3002
|
+
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
|
|
3003
|
+
return { deleted: !!(result?.deleted ?? result?.reset ?? true) };
|
|
2851
3004
|
}
|
|
2852
3005
|
/**
|
|
2853
3006
|
* Get an application definition by name or ID.
|
|
@@ -2903,19 +3056,15 @@ var ObjectStackAdapter = class {
|
|
|
2903
3056
|
*/
|
|
2904
3057
|
async updateDashboard(dashboardName, schema) {
|
|
2905
3058
|
await this.connect();
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
return result ?? void 0;
|
|
2916
|
-
} catch (err) {
|
|
2917
|
-
throw err;
|
|
2918
|
-
}
|
|
3059
|
+
const result = await this.client.meta.saveItem(
|
|
3060
|
+
"dashboard",
|
|
3061
|
+
dashboardName,
|
|
3062
|
+
schema
|
|
3063
|
+
);
|
|
3064
|
+
this.metadataCache.invalidate?.("dashboards");
|
|
3065
|
+
this.metadataCache.invalidate?.(`dashboard:${dashboardName}`);
|
|
3066
|
+
if (result && result.item) return result.item;
|
|
3067
|
+
return result ?? void 0;
|
|
2919
3068
|
}
|
|
2920
3069
|
/**
|
|
2921
3070
|
* Perform server-side aggregation via the ObjectStack analytics API.
|
|
@@ -3287,6 +3436,7 @@ function createObjectStackAdapter(config) {
|
|
|
3287
3436
|
isErrorType,
|
|
3288
3437
|
isObjectStackError,
|
|
3289
3438
|
normaliseClientError,
|
|
3439
|
+
readTransactionalBatchCapability,
|
|
3290
3440
|
snapToGrid,
|
|
3291
3441
|
validatePluginContract
|
|
3292
3442
|
});
|