@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/dist/index.js CHANGED
@@ -71,6 +71,72 @@ function convertFiltersToAST(filter) {
71
71
  return ["and", ...conditions];
72
72
  }
73
73
 
74
+ // ../core/src/adapters/batchTransaction.ts
75
+ function idOf(rec) {
76
+ if (rec == null) return void 0;
77
+ return rec.id ?? rec._id ?? rec.recordId;
78
+ }
79
+ function resolveRefs(data, results) {
80
+ const out = {};
81
+ for (const [key, value] of Object.entries(data)) {
82
+ if (value && typeof value === "object" && !Array.isArray(value) && typeof value.$ref === "number") {
83
+ const refIndex = value.$ref;
84
+ const id = idOf(results[refIndex]);
85
+ if (id === void 0) {
86
+ throw new Error(
87
+ `batchTransaction: field "${key}" references operation ${refIndex}, which has not produced an id (forward or invalid $ref)`
88
+ );
89
+ }
90
+ out[key] = id;
91
+ } else {
92
+ out[key] = value;
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+ async function emulateBatchTransaction(dataSource, operations) {
98
+ const results = [];
99
+ const created = [];
100
+ try {
101
+ for (const op of operations) {
102
+ const action = op.action ?? "create";
103
+ if (action === "create") {
104
+ const record = await dataSource.create(
105
+ op.object,
106
+ resolveRefs(op.data ?? {}, results)
107
+ );
108
+ results.push(record);
109
+ const id = idOf(record);
110
+ if (id) created.push({ object: op.object, id });
111
+ } else if (action === "update") {
112
+ if (op.id == null) {
113
+ throw new Error(`batchTransaction: update on "${op.object}" requires an id`);
114
+ }
115
+ results.push(
116
+ await dataSource.update(
117
+ op.object,
118
+ op.id,
119
+ resolveRefs(op.data ?? {}, results)
120
+ )
121
+ );
122
+ } else {
123
+ if (op.id == null) {
124
+ throw new Error(`batchTransaction: delete on "${op.object}" requires an id`);
125
+ }
126
+ results.push(await dataSource.delete(op.object, op.id));
127
+ }
128
+ }
129
+ return { results };
130
+ } catch (err) {
131
+ if (created.length > 0) {
132
+ await Promise.allSettled(
133
+ [...created].reverse().map((c) => dataSource.delete(c.object, c.id))
134
+ );
135
+ }
136
+ throw err;
137
+ }
138
+ }
139
+
74
140
  // ../core/src/utils/reference-keys.ts
75
141
  function normalizeFieldReferenceKeys(fieldDef) {
76
142
  if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
@@ -271,893 +337,898 @@ var MetadataCache = class {
271
337
  }
272
338
  };
273
339
 
274
- // src/errors.ts
275
- var ObjectStackError = class extends Error {
340
+ // src/metadata-client.ts
341
+ var META_PREFIX = "/meta";
342
+ var API_PREFIX = "/api/v1";
343
+ function buildBase(config) {
344
+ const trimmed = config.baseUrl.replace(/\/+$/, "");
345
+ const scoped = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
346
+ if (/\/api\/v\d+$/i.test(trimmed)) {
347
+ const stripped = trimmed.replace(/\/api\/v\d+$/i, "");
348
+ const scopedNoApi = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
349
+ return `${stripped}${scopedNoApi}`;
350
+ }
351
+ return `${trimmed}${scoped}`;
352
+ }
353
+ async function parseError(res) {
354
+ let body;
355
+ try {
356
+ body = await res.json();
357
+ } catch {
358
+ body = await res.text().catch(() => void 0);
359
+ }
360
+ const bodyObj = body && typeof body === "object" ? body : void 0;
361
+ const errNode = bodyObj?.error;
362
+ const message = (errNode && typeof errNode === "object" ? errNode.message : errNode) ?? bodyObj?.message ?? `Metadata request failed: ${res.status} ${res.statusText}`;
363
+ const err = new Error(String(message));
364
+ err.status = res.status;
365
+ err.body = body;
366
+ const details = errNode && typeof errNode === "object" ? errNode.details : void 0;
367
+ const issues = details?.issues ?? (errNode && typeof errNode === "object" ? errNode.issues : void 0) ?? bodyObj?.issues;
368
+ if (Array.isArray(issues)) err.issues = issues;
369
+ const code = details?.code ?? (errNode && typeof errNode === "object" ? errNode.code : void 0) ?? bodyObj?.code;
370
+ if (code != null) err.code = String(code);
371
+ return err;
372
+ }
373
+ var MetadataClient = class _MetadataClient {
374
+ constructor(config) {
375
+ __publicField(this, "base");
376
+ __publicField(this, "fetchImpl");
377
+ __publicField(this, "headers");
378
+ /** ADR-0037: when true, reads render the draft-overlaid world. */
379
+ __publicField(this, "previewDrafts");
380
+ this.base = buildBase(config);
381
+ this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
382
+ this.headers = { Accept: "application/json", ...config.headers ?? {} };
383
+ this.previewDrafts = config.previewDrafts === true;
384
+ }
385
+ /** Update the client's environment scope at runtime. */
386
+ withEnvironment(environmentId) {
387
+ return new _MetadataClient({
388
+ baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
389
+ environmentId,
390
+ fetch: this.fetchImpl,
391
+ headers: this.headers,
392
+ previewDrafts: this.previewDrafts
393
+ });
394
+ }
276
395
  /**
277
- * Create a new ObjectStackError
278
- *
279
- * @param message - Human-readable error message
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
396
+ * Derive a client whose reads render the draft-overlaid world (or not).
397
+ * Same base/fetch/headers; used by the app-shell to switch the whole
398
+ * renderer tree into ADR-0037 preview mode off one URL flag.
283
399
  */
284
- constructor(message, code, statusCode, details) {
285
- super(message);
286
- __publicField(this, "code", code);
287
- __publicField(this, "statusCode", statusCode);
288
- __publicField(this, "details", details);
289
- this.name = "ObjectStackError";
290
- if (Error.captureStackTrace) {
291
- Error.captureStackTrace(this, this.constructor);
400
+ withPreviewDrafts(previewDrafts) {
401
+ if (previewDrafts === this.previewDrafts) return this;
402
+ const envMatch = this.base.match(/\/api\/v\d+\/environments\/([^/]+)\/meta$/);
403
+ return new _MetadataClient({
404
+ baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
405
+ ...envMatch ? { environmentId: decodeURIComponent(envMatch[1]) } : {},
406
+ fetch: this.fetchImpl,
407
+ headers: this.headers,
408
+ previewDrafts
409
+ });
410
+ }
411
+ /** List all registered metadata types (returns the registry rows). */
412
+ async listTypes() {
413
+ const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
414
+ if (!res.ok) throw await parseError(res);
415
+ const data = await res.json();
416
+ if (Array.isArray(data)) return data;
417
+ if (data && Array.isArray(data.entries)) return data.entries;
418
+ if (data && Array.isArray(data.items)) return data.items;
419
+ if (data && Array.isArray(data.types)) {
420
+ return data.types.map((t) => ({ type: t }));
421
+ }
422
+ return [];
423
+ }
424
+ /** List items of a metadata type (e.g. `object`, `field`, `view`). */
425
+ async list(type, options = {}) {
426
+ const params = [];
427
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
428
+ if (this.previewDrafts) params.push("preview=draft");
429
+ const qs = params.length ? `?${params.join("&")}` : "";
430
+ const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
431
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
432
+ if (!res.ok) throw await parseError(res);
433
+ const data = await res.json();
434
+ if (Array.isArray(data)) return data;
435
+ if (data && Array.isArray(data.items)) {
436
+ return data.items;
292
437
  }
438
+ return [];
293
439
  }
294
440
  /**
295
- * Convert error to JSON for logging/debugging
441
+ * List pending DRAFT items (ADR-0033) — what an AI authored but nobody
442
+ * published yet. `list()` only sees published/active metadata, so a
443
+ * just-built app package looks empty there; this surfaces the drafts so the
444
+ * console can show a "pending changes" view and draft-aware package contents.
445
+ * Optionally narrow by `packageId` and/or `type`. Returns light headers
446
+ * (no body) carrying `packageId` for grouping.
296
447
  */
297
- toJSON() {
298
- return {
299
- name: this.name,
300
- message: this.message,
301
- code: this.code,
302
- statusCode: this.statusCode,
303
- details: this.details,
304
- stack: this.stack
305
- };
448
+ async listDrafts(options = {}) {
449
+ const params = [];
450
+ if (options.packageId) params.push(`packageId=${encodeURIComponent(options.packageId)}`);
451
+ if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
452
+ const qs = params.length ? `?${params.join("&")}` : "";
453
+ const url = `${this.base}/_drafts${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
+ return data?.drafts ?? data?.data?.drafts ?? [];
306
459
  }
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";
460
+ /**
461
+ * Get a single metadata item. Returns the unwrapped item content
462
+ * (matching the framework REST handler which calls `res.json(item)`).
463
+ * Returns `null` on 404 to keep the call-site ergonomic.
464
+ */
465
+ async get(type, name, options = {}) {
466
+ const params = [];
467
+ if (options.state === "draft") params.push("state=draft");
468
+ else if (this.previewDrafts) params.push("preview=draft");
469
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
470
+ const qs = params.length ? `?${params.join("&")}` : "";
471
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
472
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
473
+ if (res.status === 404) return null;
474
+ if (!res.ok) throw await parseError(res);
475
+ return await res.json();
317
476
  }
318
- };
319
- var BulkOperationError = class extends ObjectStackError {
320
477
  /**
321
- * Create a new BulkOperationError
322
- *
323
- * @param operation - The bulk operation that failed (create, update, delete)
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
478
+ * Read the pending draft body for an item (`?state=draft`). Returns
479
+ * `null` when there is no draft pending. Draft reads do NOT fall
480
+ * back to the published overlay or the artifact registry — a `null`
481
+ * unambiguously means "nothing to publish".
482
+ *
483
+ * Note: the framework wraps draft responses in an envelope
484
+ * `{ type, name, item }` (matching `getMetaItem`); callers should
485
+ * read `.item` to get the body. The legacy `get()` returns the
486
+ * unwrapped body, so this method preserves that asymmetry by
487
+ * returning whatever the server sent.
328
488
  */
329
- constructor(operation, successCount, failureCount, errors, details) {
330
- super(
331
- `Bulk ${operation} operation failed: ${successCount} succeeded, ${failureCount} failed`,
332
- "BULK_OPERATION_ERROR",
333
- 500,
334
- {
335
- operation,
336
- successCount,
337
- failureCount,
338
- errors,
339
- ...details
340
- }
341
- );
342
- __publicField(this, "successCount", successCount);
343
- __publicField(this, "failureCount", failureCount);
344
- __publicField(this, "errors", errors);
345
- this.name = "BulkOperationError";
489
+ async getDraft(type, name, options = {}) {
490
+ return this.get(type, name, { state: "draft", ...options.packageId ? { packageId: options.packageId } : {} });
346
491
  }
347
492
  /**
348
- * Get a summary of the bulk operation failure
493
+ * Save (PUT) a metadata item. The framework accepts both the bare
494
+ * item payload and the `{ item: ... }` / `{ metadata: ... }`
495
+ * envelopes; we send bare for consistency. Pass `mode: 'draft'` to
496
+ * stage the change without publishing (Studio's "Save" button).
349
497
  */
350
- getSummary() {
351
- const total = this.successCount + this.failureCount;
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
498
+ async save(type, name, item, options = {}) {
499
+ if (!name || !String(name).trim()) {
500
+ throw new Error(
501
+ `MetadataClient.save: name must be non-empty (type="${type}"). The PUT /meta/:type/:name route requires a name segment.`
502
+ );
503
+ }
504
+ const params = [];
505
+ if (options.force) params.push("force=true");
506
+ if (options.mode === "draft") params.push("mode=draft");
507
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
508
+ const qs = params.length ? `?${params.join("&")}` : "";
509
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
510
+ const headers = {
511
+ ...this.headers,
512
+ "Content-Type": "application/json"
360
513
  };
514
+ if (options.ifMatch) headers["If-Match"] = options.ifMatch;
515
+ if (options.actor) headers["X-Actor"] = options.actor;
516
+ const res = await this.fetchImpl(url, {
517
+ method: "PUT",
518
+ headers,
519
+ body: JSON.stringify(item)
520
+ });
521
+ if (!res.ok) throw await parseError(res);
522
+ return await res.json();
361
523
  }
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
524
  /**
388
- * Create a new ValidationError
389
- *
390
- * @param message - Human-readable error message
391
- * @param field - The field that failed validation (optional)
392
- * @param validationErrors - Array of validation error details
393
- * @param details - Additional error details
525
+ * Publish a single pending draft BY REFERENCE — promotes the draft row for
526
+ * `(type, name)` into the active overlay and drops the draft. Works for ANY
527
+ * draft, including ones with no `packageId` binding (which the package-scoped
528
+ * `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
529
+ * exact set returned by {@link listDrafts} without needing a package.
530
+ *
531
+ * Returns the server result. For `seed` drafts the protocol also materializes
532
+ * the rows and reports under `seedApplied` — a data problem never fails the
533
+ * publish, so callers should check `seedApplied?.success` and warn the user
534
+ * rather than assume the data went live.
394
535
  */
395
- constructor(message, field, validationErrors, details) {
396
- super(
397
- message,
398
- "VALIDATION_ERROR",
399
- 400,
400
- {
401
- field,
402
- validationErrors,
403
- ...details
404
- }
405
- );
406
- __publicField(this, "field", field);
407
- __publicField(this, "validationErrors", validationErrors);
408
- this.name = "ValidationError";
536
+ async publishDraft(type, name) {
537
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
538
+ const res = await this.fetchImpl(url, {
539
+ method: "POST",
540
+ headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json" },
541
+ body: "{}"
542
+ });
543
+ if (!res.ok) throw await parseError(res);
544
+ const body = await res.json().catch(() => ({}));
545
+ const inner = body?.data && typeof body.data === "object" ? body.data : body;
546
+ return inner;
409
547
  }
410
548
  /**
411
- * Get all validation errors as a formatted list
549
+ * Get the 3-state layered view of a metadata item (Phase 3a). Returns
550
+ * `code` (the artifact / fallback default), `overlay` (the saved
551
+ * customisation, if any), and `effective` (what the runtime sees).
412
552
  */
413
- getValidationErrors() {
414
- return this.validationErrors || [];
415
- }
416
- };
417
- function createErrorFromResponse(response, context) {
418
- const status = response?.status || response?.statusCode || 500;
419
- const message = response?.message || response?.statusText || "Unknown error";
420
- const details = {
421
- context,
422
- response: {
423
- status,
424
- data: response?.data,
425
- headers: response?.headers
553
+ async layered(type, name, options = {}) {
554
+ const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : "";
555
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
556
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
557
+ if (res.status === 404) {
558
+ return { code: null, overlay: null, overlayScope: null, effective: null };
426
559
  }
427
- };
428
- switch (status) {
429
- case 401:
430
- return new AuthenticationError(message, details, 401);
431
- case 403:
432
- return new AuthenticationError(message, details, 403);
433
- case 404:
434
- if (context?.includes("metadata") || context?.includes("schema") || context?.includes("getObjectSchema")) {
435
- const objectName = extractObjectName(context);
436
- return new MetadataNotFoundError(objectName, details);
437
- }
438
- return new ObjectStackError(message, "NOT_FOUND", 404, details);
439
- case 400:
440
- return new ValidationError(message, void 0, response?.data?.errors, details);
441
- case 503:
442
- return new ConnectionError(message, response?.config?.url, details, 503);
443
- case 504:
444
- return new ConnectionError(message, response?.config?.url, details, 504);
445
- default:
446
- return new ObjectStackError(message, "UNKNOWN_ERROR", status, details);
447
- }
448
- }
449
- function extractObjectName(context) {
450
- if (!context) return "unknown";
451
- const match = context.match(/\(([^)]+)\)/);
452
- return match ? match[1] : "unknown";
453
- }
454
- function isObjectStackError(error) {
455
- return error instanceof ObjectStackError;
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);
560
+ if (!res.ok) throw await parseError(res);
561
+ const body = await res.json();
562
+ const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
563
+ if (!hasEnvelope) {
564
+ return {
565
+ code: null,
566
+ overlay: null,
567
+ overlayScope: null,
568
+ effective: body
569
+ };
570
+ }
571
+ return {
572
+ code: body.code ?? null,
573
+ overlay: body.overlay ?? null,
574
+ overlayScope: body.overlayScope ?? null,
575
+ effective: body.effective ?? null,
576
+ ...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
577
+ // ADR-0010 Phase 4 — pass through the metadata-protection envelope so
578
+ // the editor can render lock affordances without a second round trip.
579
+ ...body.lock !== void 0 ? { lock: body.lock } : {},
580
+ ...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
581
+ ...body.lockSource !== void 0 ? { lockSource: body.lockSource } : {},
582
+ ...body.lockDocsUrl !== void 0 ? { lockDocsUrl: body.lockDocsUrl } : {},
583
+ ...body.provenance !== void 0 ? { provenance: body.provenance } : {},
584
+ ...body.packageId !== void 0 ? { packageId: body.packageId } : {},
585
+ ...body.packageVersion !== void 0 ? { packageVersion: body.packageVersion } : {},
586
+ ...body.editable !== void 0 ? { editable: body.editable } : {},
587
+ ...body.deletable !== void 0 ? { deletable: body.deletable } : {},
588
+ ...body.resettable !== void 0 ? { resettable: body.resettable } : {}
589
+ };
465
590
  }
466
591
  /**
467
- * Deploy an application to the cloud.
592
+ * Cross-type sweep of load-time validation results — calls
593
+ * `GET /meta/diagnostics`. Returns every entry the framework
594
+ * considers invalid (or, when `severity: 'warning'`, also entries
595
+ * with only warnings).
596
+ *
597
+ * Used by the Studio's governance overview page and by the
598
+ * directory page to show "N invalid" badges per metadata type.
468
599
  */
469
- async deploy(appId, config) {
470
- const client = this.getClient();
471
- const result = await client.cloud?.deploy?.(appId, config);
472
- return result ?? { deploymentId: `deploy-${Date.now()}`, status: "pending" };
600
+ async diagnostics(options = {}) {
601
+ const params = [];
602
+ if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
603
+ if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
604
+ if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
605
+ const qs = params.length ? `?${params.join("&")}` : "";
606
+ const url = `${this.base}/diagnostics${qs}`;
607
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
608
+ if (res.status === 404) {
609
+ return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
610
+ }
611
+ if (!res.ok) throw await parseError(res);
612
+ const data = await res.json();
613
+ return {
614
+ entries: Array.isArray(data.entries) ? data.entries : [],
615
+ total: typeof data.total === "number" ? data.total : 0,
616
+ scannedTypes: typeof data.scannedTypes === "number" ? data.scannedTypes : 0,
617
+ scannedItems: typeof data.scannedItems === "number" ? data.scannedItems : 0,
618
+ stats: data.stats && typeof data.stats === "object" ? data.stats : {}
619
+ };
473
620
  }
474
621
  /**
475
- * Get deployment status.
622
+ * Find every metadata item that references this one (Phase 3a). Useful
623
+ * for pre-delete impact analysis: "Are any views pointing at this
624
+ * object before I drop it?".
476
625
  */
477
- async getDeploymentStatus(deploymentId) {
478
- const client = this.getClient();
479
- const result = await client.cloud?.getDeployment?.(deploymentId);
480
- return result ?? { status: "unknown" };
626
+ async references(type, name) {
627
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
628
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
629
+ if (res.status === 404) return [];
630
+ if (!res.ok) throw await parseError(res);
631
+ const data = await res.json();
632
+ return Array.isArray(data) ? data : data?.items ?? [];
481
633
  }
482
634
  /**
483
- * Search marketplace entries.
635
+ * Reset a metadata customization overlay back to the artifact default.
636
+ * Idempotent: returns the result even when no overlay row existed.
637
+ * Pass `state: 'draft'` to discard the pending draft only (keeps the
638
+ * published overlay intact) — useful for a Studio "Discard draft" button.
484
639
  */
485
- async searchMarketplace(query, category) {
486
- const client = this.getClient();
487
- const result = await client.cloud?.marketplace?.search?.({ query, category });
488
- return result?.items ?? [];
640
+ async reset(type, name, options = {}) {
641
+ const qs = options.state === "draft" ? "?state=draft" : "";
642
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
643
+ const headers = { ...this.headers };
644
+ if (options.ifMatch) headers["If-Match"] = options.ifMatch;
645
+ if (options.actor) headers["X-Actor"] = options.actor;
646
+ const res = await this.fetchImpl(url, { method: "DELETE", headers });
647
+ if (!res.ok) throw await parseError(res);
648
+ return await res.json();
489
649
  }
490
650
  /**
491
- * Install a marketplace plugin.
651
+ * Promote the pending draft of an item to the active overlay
652
+ * (POST `/meta/:type/:name/publish`). Returns
653
+ * `{ success, version, seq, message }`. Throws a `404 no_draft` if
654
+ * nothing is pending and `409 metadata_conflict` if the published
655
+ * overlay moved while the draft was sitting.
492
656
  */
493
- async installPlugin(pluginId) {
494
- const client = this.getClient();
495
- const result = await client.cloud?.marketplace?.install?.(pluginId);
496
- return result ?? { success: false };
657
+ async publish(type, name, options = {}) {
658
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
659
+ const headers = {
660
+ ...this.headers,
661
+ "Content-Type": "application/json"
662
+ };
663
+ if (options.actor) headers["X-Actor"] = options.actor;
664
+ const res = await this.fetchImpl(url, {
665
+ method: "POST",
666
+ headers,
667
+ body: JSON.stringify(options.message ? { message: options.message } : {})
668
+ });
669
+ if (!res.ok) throw await parseError(res);
670
+ return await res.json();
497
671
  }
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
672
+ /**
673
+ * Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
674
+ * The server reads the history row at `toVersion`, writes its body back
675
+ * as the active overlay with `operation_type='revert'`. Returns
676
+ * `{ success, version, seq, restoredFromVersion, message }`. Throws
677
+ * `404 version_not_found` for unknown versions and `409 version_not_restorable`
678
+ * when the target version is a delete tombstone.
679
+ */
680
+ async rollback(type, name, toVersion, options = {}) {
681
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
682
+ const headers = {
683
+ ...this.headers,
684
+ "Content-Type": "application/json"
566
685
  };
567
- const result = await dataSource.find(resource, params);
568
- const rows = result?.data ?? [];
569
- const match = rows.find((r) => {
570
- if (!r) return false;
571
- const userMismatch = r.user_id !== void 0 && r.user_id !== userId;
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({
691
+ toVersion,
692
+ ...options.message ? { message: options.message } : {}
693
+ })
574
694
  });
575
- return match ?? null;
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());
695
+ if (!res.ok) throw await parseError(res);
696
+ return await res.json();
654
697
  }
655
698
  /**
656
- * Register a new integration.
699
+ * Compute a structured top-level key diff between two history versions
700
+ * (GET `/meta/:type/:name/diff?from=&to=`). Returns
701
+ * `{ added, removed, changed }` where each entry carries a `path` and
702
+ * the relevant value(s). Useful for "what changed in this draft?" and
703
+ * pre-rollback previews.
657
704
  */
658
- register(id, config) {
659
- this.integrations.set(id, config);
705
+ async diff(type, name, fromVersion, toVersion) {
706
+ const params = new URLSearchParams();
707
+ if (fromVersion !== void 0) params.set("from", String(fromVersion));
708
+ if (toVersion !== void 0) params.set("to", String(toVersion));
709
+ const qs = params.toString();
710
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ""}`;
711
+ const res = await this.fetchImpl(url, {
712
+ method: "GET",
713
+ headers: this.headers,
714
+ cache: "no-store"
715
+ });
716
+ if (!res.ok) throw await parseError(res);
717
+ return await res.json();
660
718
  }
661
- /**
662
- * Remove an integration.
663
- */
664
- unregister(id) {
665
- this.integrations.delete(id);
719
+ /** Fetch the durable history (change log) for one metadata item. */
720
+ async history(type, name, options = {}) {
721
+ const params = new URLSearchParams();
722
+ if (options.sinceSeq !== void 0) params.set("sinceSeq", String(options.sinceSeq));
723
+ if (options.limit !== void 0) params.set("limit", String(options.limit));
724
+ const qs = params.toString();
725
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ""}`;
726
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
727
+ if (!res.ok) throw await parseError(res);
728
+ return await res.json();
666
729
  }
667
730
  /**
668
- * Get all registered integrations.
731
+ * Fetch the protection-audit trail for one metadata item
732
+ * (ADR-0010 §3.6 / Phase 4.1). Shows every save/publish/rollback/
733
+ * delete/reset attempt — allowed, denied, or forced — so the
734
+ * Studio "审计日志 / Audit log" tab can render who tried what
735
+ * and whether a lock blocked it.
669
736
  */
670
- getAll() {
671
- return new Map(this.integrations);
737
+ async audit(type, name, options = {}) {
738
+ const params = new URLSearchParams();
739
+ if (options.limit !== void 0) params.set("limit", String(options.limit));
740
+ const qs = params.toString();
741
+ const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ""}`;
742
+ const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
743
+ if (!res.ok) throw await parseError(res);
744
+ return await res.json();
672
745
  }
746
+ };
747
+
748
+ // src/errors.ts
749
+ var ObjectStackError = class extends Error {
673
750
  /**
674
- * Get integrations that match a specific event.
751
+ * Create a new ObjectStackError
752
+ *
753
+ * @param message - Human-readable error message
754
+ * @param code - Unique error code for programmatic handling
755
+ * @param statusCode - Optional HTTP status code
756
+ * @param details - Optional additional error details for debugging
675
757
  */
676
- getForEvent(event) {
677
- return Array.from(this.integrations.values()).filter(
678
- (integration) => integration.enabled && integration.triggers?.some((t) => t.event === event)
679
- );
758
+ constructor(message, code, statusCode, details) {
759
+ super(message);
760
+ __publicField(this, "code", code);
761
+ __publicField(this, "statusCode", statusCode);
762
+ __publicField(this, "details", details);
763
+ this.name = "ObjectStackError";
764
+ if (Error.captureStackTrace) {
765
+ Error.captureStackTrace(this, this.constructor);
766
+ }
680
767
  }
681
768
  /**
682
- * Dispatch an event to all matching integrations.
683
- * Returns results for each integration.
769
+ * Convert error to JSON for logging/debugging
684
770
  */
685
- async dispatch(event, payload) {
686
- const matching = this.getForEvent(event);
687
- const results = [];
688
- for (const [id, integration] of this.integrations) {
689
- if (!matching.includes(integration)) continue;
690
- try {
691
- await this.send(integration, payload);
692
- results.push({ id, success: true });
693
- } catch (err) {
694
- results.push({ id, success: false, error: err.message });
695
- }
696
- }
697
- return results;
771
+ toJSON() {
772
+ return {
773
+ name: this.name,
774
+ message: this.message,
775
+ code: this.code,
776
+ statusCode: this.statusCode,
777
+ details: this.details,
778
+ stack: this.stack
779
+ };
780
+ }
781
+ };
782
+ var MetadataNotFoundError = class extends ObjectStackError {
783
+ constructor(objectName, details) {
784
+ super(
785
+ `Metadata not found for object: ${objectName}`,
786
+ "METADATA_NOT_FOUND",
787
+ 404,
788
+ { objectName, ...details }
789
+ );
790
+ this.name = "MetadataNotFoundError";
698
791
  }
792
+ };
793
+ var BulkOperationError = class extends ObjectStackError {
699
794
  /**
700
- * Send payload to a specific integration.
795
+ * Create a new BulkOperationError
796
+ *
797
+ * @param operation - The bulk operation that failed (create, update, delete)
798
+ * @param successCount - Number of successful operations
799
+ * @param failureCount - Number of failed operations
800
+ * @param errors - Array of individual errors
801
+ * @param details - Additional error details
701
802
  */
702
- async send(integration, payload) {
703
- switch (integration.provider) {
704
- case "webhook": {
705
- const cfg = integration.config;
706
- const url = cfg.url;
707
- try {
708
- const parsed = new URL(url);
709
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
710
- throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
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;
803
+ constructor(operation, successCount, failureCount, errors, details) {
804
+ super(
805
+ `Bulk ${operation} operation failed: ${successCount} succeeded, ${failureCount} failed`,
806
+ "BULK_OPERATION_ERROR",
807
+ 500,
808
+ {
809
+ operation,
810
+ successCount,
811
+ failureCount,
812
+ errors,
813
+ ...details
753
814
  }
754
- // Email and other providers would require server-side implementation
755
- default:
756
- break;
757
- }
758
- }
759
- };
760
-
761
- // src/metadata-client.ts
762
- var META_PREFIX = "/meta";
763
- var API_PREFIX = "/api/v1";
764
- function buildBase(config) {
765
- const trimmed = config.baseUrl.replace(/\/+$/, "");
766
- const scoped = config.environmentId ? `${API_PREFIX}/environments/${encodeURIComponent(config.environmentId)}${META_PREFIX}` : `${API_PREFIX}${META_PREFIX}`;
767
- if (/\/api\/v\d+$/i.test(trimmed)) {
768
- const stripped = trimmed.replace(/\/api\/v\d+$/i, "");
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
- }
806
- /** Update the client's environment scope at runtime. */
807
- withEnvironment(environmentId) {
808
- return new _MetadataClient({
809
- baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
810
- environmentId,
811
- fetch: this.fetchImpl,
812
- headers: this.headers,
813
- previewDrafts: this.previewDrafts
814
- });
815
+ );
816
+ __publicField(this, "successCount", successCount);
817
+ __publicField(this, "failureCount", failureCount);
818
+ __publicField(this, "errors", errors);
819
+ this.name = "BulkOperationError";
815
820
  }
816
821
  /**
817
- * Derive a client whose reads render the draft-overlaid world (or not).
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.
822
+ * Get a summary of the bulk operation failure
820
823
  */
821
- withPreviewDrafts(previewDrafts) {
822
- if (previewDrafts === this.previewDrafts) return this;
823
- const envMatch = this.base.match(/\/api\/v\d+\/environments\/([^/]+)\/meta$/);
824
- return new _MetadataClient({
825
- baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
826
- ...envMatch ? { environmentId: decodeURIComponent(envMatch[1]) } : {},
827
- fetch: this.fetchImpl,
828
- headers: this.headers,
829
- previewDrafts
830
- });
824
+ getSummary() {
825
+ const total = this.successCount + this.failureCount;
826
+ const failureRate = total > 0 ? this.failureCount / total : 0;
827
+ return {
828
+ operation: this.details?.operation,
829
+ total,
830
+ successful: this.successCount,
831
+ failed: this.failureCount,
832
+ failureRate,
833
+ errors: this.errors
834
+ };
831
835
  }
832
- /** List all registered metadata types (returns the registry rows). */
833
- async listTypes() {
834
- const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
835
- if (!res.ok) throw await parseError(res);
836
- const data = await res.json();
837
- if (Array.isArray(data)) return data;
838
- if (data && Array.isArray(data.entries)) return data.entries;
839
- if (data && Array.isArray(data.items)) return data.items;
840
- if (data && Array.isArray(data.types)) {
841
- return data.types.map((t) => ({ type: t }));
842
- }
843
- return [];
836
+ };
837
+ var ConnectionError = class extends ObjectStackError {
838
+ constructor(message, url, details, statusCode) {
839
+ super(
840
+ `Connection error: ${message}`,
841
+ "CONNECTION_ERROR",
842
+ statusCode || 503,
843
+ { url, ...details }
844
+ );
845
+ __publicField(this, "url", url);
846
+ this.name = "ConnectionError";
844
847
  }
845
- /** List items of a metadata type (e.g. `object`, `field`, `view`). */
846
- async list(type, options = {}) {
847
- const params = [];
848
- if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
849
- if (this.previewDrafts) params.push("preview=draft");
850
- const qs = params.length ? `?${params.join("&")}` : "";
851
- const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
852
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
853
- if (!res.ok) throw await parseError(res);
854
- const data = await res.json();
855
- if (Array.isArray(data)) return data;
856
- if (data && Array.isArray(data.items)) {
857
- return data.items;
858
- }
859
- return [];
848
+ };
849
+ var AuthenticationError = class extends ObjectStackError {
850
+ constructor(message = "Authentication failed", details, statusCode) {
851
+ super(
852
+ message,
853
+ "AUTHENTICATION_ERROR",
854
+ statusCode || 401,
855
+ details
856
+ );
857
+ this.name = "AuthenticationError";
860
858
  }
859
+ };
860
+ var ValidationError = class extends ObjectStackError {
861
861
  /**
862
- * List pending DRAFT items (ADR-0033) — what an AI authored but nobody
863
- * published yet. `list()` only sees published/active metadata, so a
864
- * just-built app package looks empty there; this surfaces the drafts so the
865
- * console can show a "pending changes" view and draft-aware package contents.
866
- * Optionally narrow by `packageId` and/or `type`. Returns light headers
867
- * (no body) carrying `packageId` for grouping.
862
+ * Create a new ValidationError
863
+ *
864
+ * @param message - Human-readable error message
865
+ * @param field - The field that failed validation (optional)
866
+ * @param validationErrors - Array of validation error details
867
+ * @param details - Additional error details
868
868
  */
869
- async listDrafts(options = {}) {
870
- const params = [];
871
- if (options.packageId) params.push(`packageId=${encodeURIComponent(options.packageId)}`);
872
- if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
873
- const qs = params.length ? `?${params.join("&")}` : "";
874
- const url = `${this.base}/_drafts${qs}`;
875
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
876
- if (!res.ok) throw await parseError(res);
877
- const data = await res.json();
878
- if (Array.isArray(data)) return data;
879
- return data?.drafts ?? data?.data?.drafts ?? [];
869
+ constructor(message, field, validationErrors, details) {
870
+ super(
871
+ message,
872
+ "VALIDATION_ERROR",
873
+ 400,
874
+ {
875
+ field,
876
+ validationErrors,
877
+ ...details
878
+ }
879
+ );
880
+ __publicField(this, "field", field);
881
+ __publicField(this, "validationErrors", validationErrors);
882
+ this.name = "ValidationError";
880
883
  }
881
884
  /**
882
- * Get a single metadata item. Returns the unwrapped item content
883
- * (matching the framework REST handler which calls `res.json(item)`).
884
- * Returns `null` on 404 to keep the call-site ergonomic.
885
+ * Get all validation errors as a formatted list
885
886
  */
886
- async get(type, name, options = {}) {
887
- const params = [];
888
- if (options.state === "draft") params.push("state=draft");
889
- else if (this.previewDrafts) params.push("preview=draft");
890
- if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
891
- const qs = params.length ? `?${params.join("&")}` : "";
892
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
893
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
894
- if (res.status === 404) return null;
895
- if (!res.ok) throw await parseError(res);
896
- return await res.json();
887
+ getValidationErrors() {
888
+ return this.validationErrors || [];
889
+ }
890
+ };
891
+ function createErrorFromResponse(response, context) {
892
+ const status = response?.status || response?.statusCode || 500;
893
+ const message = response?.message || response?.statusText || "Unknown error";
894
+ const details = {
895
+ context,
896
+ response: {
897
+ status,
898
+ data: response?.data,
899
+ headers: response?.headers
900
+ }
901
+ };
902
+ switch (status) {
903
+ case 401:
904
+ return new AuthenticationError(message, details, 401);
905
+ case 403:
906
+ return new AuthenticationError(message, details, 403);
907
+ case 404:
908
+ if (context?.includes("metadata") || context?.includes("schema") || context?.includes("getObjectSchema")) {
909
+ const objectName = extractObjectName(context);
910
+ return new MetadataNotFoundError(objectName, details);
911
+ }
912
+ return new ObjectStackError(message, "NOT_FOUND", 404, details);
913
+ case 400:
914
+ return new ValidationError(message, void 0, response?.data?.errors, details);
915
+ case 503:
916
+ return new ConnectionError(message, response?.config?.url, details, 503);
917
+ case 504:
918
+ return new ConnectionError(message, response?.config?.url, details, 504);
919
+ default:
920
+ return new ObjectStackError(message, "UNKNOWN_ERROR", status, details);
921
+ }
922
+ }
923
+ function extractObjectName(context) {
924
+ if (!context) return "unknown";
925
+ const match = context.match(/\(([^)]+)\)/);
926
+ return match ? match[1] : "unknown";
927
+ }
928
+ function isObjectStackError(error) {
929
+ return error instanceof ObjectStackError;
930
+ }
931
+ function isErrorType(error, errorClass) {
932
+ return error instanceof errorClass;
933
+ }
934
+
935
+ // src/cloud.ts
936
+ var CloudOperations = class {
937
+ constructor(getClient) {
938
+ __publicField(this, "getClient", getClient);
897
939
  }
898
940
  /**
899
- * Read the pending draft body for an item (`?state=draft`). Returns
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.
941
+ * Deploy an application to the cloud.
909
942
  */
910
- async getDraft(type, name, options = {}) {
911
- return this.get(type, name, { state: "draft", ...options.packageId ? { packageId: options.packageId } : {} });
943
+ async deploy(appId, config) {
944
+ const client = this.getClient();
945
+ const result = await client.cloud?.deploy?.(appId, config);
946
+ return result ?? { deploymentId: `deploy-${Date.now()}`, status: "pending" };
912
947
  }
913
948
  /**
914
- * Save (PUT) a metadata item. The framework accepts both the bare
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).
949
+ * Get deployment status.
918
950
  */
919
- async save(type, name, item, options = {}) {
920
- const params = [];
921
- if (options.force) params.push("force=true");
922
- if (options.mode === "draft") params.push("mode=draft");
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();
951
+ async getDeploymentStatus(deploymentId) {
952
+ const client = this.getClient();
953
+ const result = await client.cloud?.getDeployment?.(deploymentId);
954
+ return result ?? { status: "unknown" };
939
955
  }
940
956
  /**
941
- * Publish a single pending draft BY REFERENCE — promotes the draft row for
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.
957
+ * Search marketplace entries.
951
958
  */
952
- async publishDraft(type, name) {
953
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
954
- const res = await this.fetchImpl(url, {
955
- method: "POST",
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;
959
+ async searchMarketplace(query, category) {
960
+ const client = this.getClient();
961
+ const result = await client.cloud?.marketplace?.search?.({ query, category });
962
+ return result?.items ?? [];
963
963
  }
964
964
  /**
965
- * Get the 3-state layered view of a metadata item (Phase 3a). Returns
966
- * `code` (the artifact / fallback default), `overlay` (the saved
967
- * customisation, if any), and `effective` (what the runtime sees).
965
+ * Install a marketplace plugin.
968
966
  */
969
- async layered(type, name, options = {}) {
970
- const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : "";
971
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
972
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
973
- if (res.status === 404) {
974
- return { code: null, overlay: null, overlayScope: null, effective: null };
975
- }
976
- if (!res.ok) throw await parseError(res);
977
- const body = await res.json();
978
- const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
979
- if (!hasEnvelope) {
980
- return {
981
- code: null,
982
- overlay: null,
983
- overlayScope: null,
984
- effective: body
985
- };
967
+ async installPlugin(pluginId) {
968
+ const client = this.getClient();
969
+ const result = await client.cloud?.marketplace?.install?.(pluginId);
970
+ return result ?? { success: false };
971
+ }
972
+ };
973
+
974
+ // src/contracts.ts
975
+ function validatePluginContract(contract) {
976
+ const errors = [];
977
+ const warnings = [];
978
+ if (!contract.name || contract.name.trim().length === 0) {
979
+ errors.push({ field: "name", message: "Plugin name is required", code: "MISSING_NAME" });
980
+ }
981
+ if (!contract.version || !/^\d+\.\d+\.\d+/.test(contract.version)) {
982
+ errors.push({ field: "version", message: "Valid semver version is required", code: "INVALID_VERSION" });
983
+ }
984
+ if (!contract.exports || contract.exports.length === 0) {
985
+ errors.push({ field: "exports", message: "At least one export is required", code: "NO_EXPORTS" });
986
+ }
987
+ if (contract.exports) {
988
+ const validTypes = ["component", "hook", "utility", "provider"];
989
+ for (const exp of contract.exports) {
990
+ if (!exp.name) {
991
+ errors.push({ field: "exports.name", message: "Export name is required", code: "MISSING_EXPORT_NAME" });
992
+ }
993
+ if (!validTypes.includes(exp.type)) {
994
+ errors.push({ field: "exports.type", message: `Invalid export type: ${exp.type}`, code: "INVALID_EXPORT_TYPE" });
995
+ }
986
996
  }
987
- return {
988
- code: body.code ?? null,
989
- overlay: body.overlay ?? null,
990
- overlayScope: body.overlayScope ?? null,
991
- effective: body.effective ?? null,
992
- ...body._diagnostics ? { _diagnostics: body._diagnostics } : {},
993
- // ADR-0010 Phase 4 — pass through the metadata-protection envelope so
994
- // the editor can render lock affordances without a second round trip.
995
- ...body.lock !== void 0 ? { lock: body.lock } : {},
996
- ...body.lockReason !== void 0 ? { lockReason: body.lockReason } : {},
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 } : {}
1005
- };
1006
997
  }
1007
- /**
1008
- * Cross-type sweep of load-time validation results — calls
1009
- * `GET /meta/diagnostics`. Returns every entry the framework
1010
- * considers invalid (or, when `severity: 'warning'`, also entries
1011
- * with only warnings).
1012
- *
1013
- * Used by the Studio's governance overview page and by the
1014
- * directory page to show "N invalid" badges per metadata type.
1015
- */
1016
- async diagnostics(options = {}) {
1017
- const params = [];
1018
- if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
1019
- if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
1020
- if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
1021
- const qs = params.length ? `?${params.join("&")}` : "";
1022
- const url = `${this.base}/diagnostics${qs}`;
1023
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
1024
- if (res.status === 404) {
1025
- return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
998
+ if (!contract.permissions || contract.permissions.length === 0) {
999
+ warnings.push("No permissions declared \u2014 plugin will have minimal access");
1000
+ }
1001
+ return { valid: errors.length === 0, errors, warnings };
1002
+ }
1003
+ function generateContractManifest(contract) {
1004
+ return {
1005
+ $schema: "https://objectui.org/schemas/plugin-contract-v1.json",
1006
+ name: contract.name,
1007
+ version: contract.version,
1008
+ peerDependencies: contract.peerDependencies ?? {},
1009
+ exports: contract.exports.map((exp) => ({
1010
+ name: exp.name,
1011
+ type: exp.type,
1012
+ description: exp.description ?? ""
1013
+ })),
1014
+ permissions: contract.permissions ?? [],
1015
+ api: contract.api ?? {},
1016
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
1017
+ };
1018
+ }
1019
+
1020
+ // src/userState.ts
1021
+ var DEFAULT_RESOURCE = "sys_user_preference";
1022
+ function createObjectStackUserStateAdapter(options) {
1023
+ const {
1024
+ dataSource,
1025
+ userId,
1026
+ key,
1027
+ resource = DEFAULT_RESOURCE,
1028
+ onError = () => {
1026
1029
  }
1027
- if (!res.ok) throw await parseError(res);
1028
- const data = await res.json();
1029
- return {
1030
- entries: Array.isArray(data.entries) ? data.entries : [],
1031
- total: typeof data.total === "number" ? data.total : 0,
1032
- scannedTypes: typeof data.scannedTypes === "number" ? data.scannedTypes : 0,
1033
- scannedItems: typeof data.scannedItems === "number" ? data.scannedItems : 0,
1034
- stats: data.stats && typeof data.stats === "object" ? data.stats : {}
1030
+ } = options;
1031
+ let cachedRowId = null;
1032
+ let saveChain = Promise.resolve();
1033
+ const findExisting = async () => {
1034
+ const params = {
1035
+ $filter: {
1036
+ user_id: userId,
1037
+ key
1038
+ },
1039
+ $top: 1
1035
1040
  };
1041
+ const result = await dataSource.find(resource, params);
1042
+ const rows = result?.data ?? [];
1043
+ const match = rows.find((r) => {
1044
+ if (!r) return false;
1045
+ const userMismatch = r.user_id !== void 0 && r.user_id !== userId;
1046
+ const keyMismatch = r.key !== void 0 && r.key !== key;
1047
+ return !userMismatch && !keyMismatch;
1048
+ });
1049
+ return match ?? null;
1050
+ };
1051
+ const decodeValue = (value) => {
1052
+ if (Array.isArray(value)) return value;
1053
+ if (typeof value === "string") {
1054
+ try {
1055
+ const parsed = JSON.parse(value);
1056
+ return Array.isArray(parsed) ? parsed : [];
1057
+ } catch {
1058
+ return [];
1059
+ }
1060
+ }
1061
+ return [];
1062
+ };
1063
+ const upsert = async (items) => {
1064
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1065
+ if (cachedRowId !== null) {
1066
+ try {
1067
+ await dataSource.update(resource, cachedRowId, { value: items, updated_at: now });
1068
+ return;
1069
+ } catch (updateError) {
1070
+ cachedRowId = null;
1071
+ onError("save", updateError);
1072
+ }
1073
+ }
1074
+ const existing = await findExisting();
1075
+ if (existing && existing.id !== void 0 && existing.id !== null) {
1076
+ cachedRowId = existing.id;
1077
+ await dataSource.update(resource, existing.id, { value: items, updated_at: now });
1078
+ return;
1079
+ }
1080
+ try {
1081
+ const created = await dataSource.create(resource, {
1082
+ user_id: userId,
1083
+ key,
1084
+ value: items,
1085
+ updated_at: now
1086
+ });
1087
+ const newId = created?.id;
1088
+ if (newId !== void 0 && newId !== null) cachedRowId = newId;
1089
+ } catch (createError) {
1090
+ const recovered = await findExisting();
1091
+ if (recovered && recovered.id !== void 0 && recovered.id !== null) {
1092
+ cachedRowId = recovered.id;
1093
+ await dataSource.update(resource, recovered.id, { value: items, updated_at: now });
1094
+ return;
1095
+ }
1096
+ throw createError;
1097
+ }
1098
+ };
1099
+ return {
1100
+ async load() {
1101
+ try {
1102
+ const row = await findExisting();
1103
+ if (!row) {
1104
+ cachedRowId = null;
1105
+ return [];
1106
+ }
1107
+ if (row.id !== void 0 && row.id !== null) cachedRowId = row.id;
1108
+ return decodeValue(row.value);
1109
+ } catch (error) {
1110
+ onError("load", error);
1111
+ return [];
1112
+ }
1113
+ },
1114
+ async save(items) {
1115
+ const run = saveChain.then(() => upsert(items)).catch((error) => {
1116
+ onError("save", error);
1117
+ });
1118
+ saveChain = run;
1119
+ return run;
1120
+ }
1121
+ };
1122
+ }
1123
+
1124
+ // src/integration.ts
1125
+ var IntegrationManager = class {
1126
+ constructor() {
1127
+ __publicField(this, "integrations", /* @__PURE__ */ new Map());
1036
1128
  }
1037
1129
  /**
1038
- * Find every metadata item that references this one (Phase 3a). Useful
1039
- * for pre-delete impact analysis: "Are any views pointing at this
1040
- * object before I drop it?".
1130
+ * Register a new integration.
1041
1131
  */
1042
- async references(type, name) {
1043
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
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 ?? [];
1132
+ register(id, config) {
1133
+ this.integrations.set(id, config);
1049
1134
  }
1050
1135
  /**
1051
- * Reset a metadata customization overlay back to the artifact default.
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.
1136
+ * Remove an integration.
1055
1137
  */
1056
- async reset(type, name, options = {}) {
1057
- const qs = options.state === "draft" ? "?state=draft" : "";
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();
1138
+ unregister(id) {
1139
+ this.integrations.delete(id);
1065
1140
  }
1066
1141
  /**
1067
- * Promote the pending draft of an item to the active overlay
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.
1142
+ * Get all registered integrations.
1072
1143
  */
1073
- async publish(type, name, options = {}) {
1074
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
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();
1144
+ getAll() {
1145
+ return new Map(this.integrations);
1087
1146
  }
1088
1147
  /**
1089
- * Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
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.
1148
+ * Get integrations that match a specific event.
1095
1149
  */
1096
- async rollback(type, name, toVersion, options = {}) {
1097
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
1098
- const headers = {
1099
- ...this.headers,
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();
1150
+ getForEvent(event) {
1151
+ return Array.from(this.integrations.values()).filter(
1152
+ (integration) => integration.enabled && integration.triggers?.some((t) => t.event === event)
1153
+ );
1113
1154
  }
1114
1155
  /**
1115
- * Compute a structured top-level key diff between two history versions
1116
- * (GET `/meta/:type/:name/diff?from=&to=`). Returns
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.
1156
+ * Dispatch an event to all matching integrations.
1157
+ * Returns results for each integration.
1120
1158
  */
1121
- async diff(type, name, fromVersion, toVersion) {
1122
- const params = new URLSearchParams();
1123
- if (fromVersion !== void 0) params.set("from", String(fromVersion));
1124
- if (toVersion !== void 0) params.set("to", String(toVersion));
1125
- const qs = params.toString();
1126
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ""}`;
1127
- const res = await this.fetchImpl(url, {
1128
- method: "GET",
1129
- headers: this.headers,
1130
- cache: "no-store"
1131
- });
1132
- if (!res.ok) throw await parseError(res);
1133
- return await res.json();
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();
1159
+ async dispatch(event, payload) {
1160
+ const matching = this.getForEvent(event);
1161
+ const results = [];
1162
+ for (const [id, integration] of this.integrations) {
1163
+ if (!matching.includes(integration)) continue;
1164
+ try {
1165
+ await this.send(integration, payload);
1166
+ results.push({ id, success: true });
1167
+ } catch (err) {
1168
+ results.push({ id, success: false, error: err.message });
1169
+ }
1170
+ }
1171
+ return results;
1145
1172
  }
1146
1173
  /**
1147
- * Fetch the protection-audit trail for one metadata item
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.
1174
+ * Send payload to a specific integration.
1152
1175
  */
1153
- async audit(type, name, options = {}) {
1154
- const params = new URLSearchParams();
1155
- if (options.limit !== void 0) params.set("limit", String(options.limit));
1156
- const qs = params.toString();
1157
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ""}`;
1158
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
1159
- if (!res.ok) throw await parseError(res);
1160
- return await res.json();
1176
+ async send(integration, payload) {
1177
+ switch (integration.provider) {
1178
+ case "webhook": {
1179
+ const cfg = integration.config;
1180
+ const url = cfg.url;
1181
+ try {
1182
+ const parsed = new URL(url);
1183
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1184
+ throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
1185
+ }
1186
+ } catch (e) {
1187
+ if (e instanceof TypeError) {
1188
+ throw new Error(`Invalid webhook URL: ${url}`);
1189
+ }
1190
+ throw e;
1191
+ }
1192
+ await fetch(url, {
1193
+ method: cfg.method,
1194
+ headers: {
1195
+ "Content-Type": "application/json",
1196
+ ...cfg.headers
1197
+ },
1198
+ body: JSON.stringify(payload)
1199
+ });
1200
+ break;
1201
+ }
1202
+ case "slack": {
1203
+ const cfg = integration.config;
1204
+ const url = cfg.webhookUrl;
1205
+ try {
1206
+ const parsed = new URL(url);
1207
+ if (parsed.protocol !== "https:") {
1208
+ throw new Error(`Invalid Slack webhook URL protocol: ${parsed.protocol}`);
1209
+ }
1210
+ } catch (e) {
1211
+ if (e instanceof TypeError) {
1212
+ throw new Error(`Invalid Slack webhook URL: ${url}`);
1213
+ }
1214
+ throw e;
1215
+ }
1216
+ await fetch(url, {
1217
+ method: "POST",
1218
+ headers: { "Content-Type": "application/json" },
1219
+ body: JSON.stringify({
1220
+ channel: cfg.channel,
1221
+ username: cfg.username,
1222
+ icon_emoji: cfg.iconEmoji,
1223
+ text: JSON.stringify(payload)
1224
+ })
1225
+ });
1226
+ break;
1227
+ }
1228
+ // Email and other providers would require server-side implementation
1229
+ default:
1230
+ break;
1231
+ }
1161
1232
  }
1162
1233
  };
1163
1234
 
@@ -1398,6 +1469,16 @@ async function getSharedDiscovery(baseUrl, fetcher) {
1398
1469
  function clearSharedDiscoveryCache() {
1399
1470
  discoveryCache.clear();
1400
1471
  }
1472
+ function readTransactionalBatchCapability(discovery) {
1473
+ const caps = discovery?.capabilities;
1474
+ if (!caps || typeof caps !== "object") return void 0;
1475
+ const value = caps.transactionalBatch;
1476
+ if (typeof value === "boolean") return value;
1477
+ if (value && typeof value === "object" && typeof value.enabled === "boolean") {
1478
+ return value.enabled;
1479
+ }
1480
+ return void 0;
1481
+ }
1401
1482
  function is404Error(error) {
1402
1483
  if (!error || typeof error !== "object") return false;
1403
1484
  const err = error;
@@ -1498,6 +1579,21 @@ var ObjectStackAdapter = class {
1498
1579
  // so optional collections like sys_presence don't hammer the server with
1499
1580
  // failing requests on every record open / panel render.
1500
1581
  __publicField(this, "missingResources", /* @__PURE__ */ new Set());
1582
+ // Set once the server has told us it can't do a cross-object transactional
1583
+ // batch (the client SDK's data.batchTransaction threw HTTP 404/405/501). After
1584
+ // that, batchTransaction skips the SDK call and serves every call via the
1585
+ // client-side emulation — the non-atomic fallback lives HERE, isolated to the
1586
+ // one adapter that has to cope with a backend lacking server atomicity (#2679).
1587
+ __publicField(this, "batchUnsupported", false);
1588
+ // The server's declared cross-object atomic-batch capability, read from
1589
+ // discovery at connect() (framework #3298 / objectui #2693). `true` → the
1590
+ // backend GUARANTEES an atomic `/batch`, so batchTransaction trusts it and
1591
+ // never degrades to the non-atomic emulation (any failure surfaces as a real
1592
+ // error). `false` or `undefined` (capability absent → backend predates #3298)
1593
+ // → keep the legacy runtime-probe + emulation fallback so a save is still
1594
+ // possible; dropping it there would turn "saves, less safe" into "no save
1595
+ // path" on older backends (#2679 compatibility constraint).
1596
+ __publicField(this, "atomicBatchCapability");
1501
1597
  // Subscribers registered via onMutation(). Emitted after each successful
1502
1598
  // create/update/delete so data-bound views (ListView, ObjectView, kanban,
1503
1599
  // calendar) auto-refresh — the interface ListView relies on to reflect
@@ -1536,6 +1632,7 @@ var ObjectStackAdapter = class {
1536
1632
  return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
1537
1633
  });
1538
1634
  this.client.discoveryInfo = data;
1635
+ this.atomicBatchCapability = readTransactionalBatchCapability(data);
1539
1636
  this.connected = true;
1540
1637
  this.reconnectAttempts = 0;
1541
1638
  this.setConnectionState("connected");
@@ -1885,33 +1982,84 @@ var ObjectStackAdapter = class {
1885
1982
  * @returns Promise resolving to array of results
1886
1983
  */
1887
1984
  /**
1888
- * Cross-object transactional batch (ObjectStack #1604). Runs the operations
1889
- * in ONE server transaction — commit all or roll back all. A field value of
1890
- * `{ $ref: <earlier op index> }` resolves to that op's created id, so a child
1891
- * can reference its parent created earlier in the same batch (master-detail).
1985
+ * Cross-object transactional batch (ObjectStack #1604 / ADR-0034 item 4).
1986
+ * Runs the operations in ONE server transaction — commit all or roll back
1987
+ * all. A field value of `{ $ref: <earlier op index> }` resolves to that op's
1988
+ * created id, so a child can reference its parent created earlier in the same
1989
+ * batch (master-detail).
1990
+ *
1991
+ * Transport: the published `@objectstack/client` SDK method
1992
+ * `data.batchTransaction` (framework #3271; shipped since client v16, our
1993
+ * dependency floor). Per AGENTS.md §7 data always flows through the client —
1994
+ * never a hand-rolled `fetch('/api/v1/batch')`.
1995
+ *
1996
+ * Fallback decision — declarative capability negotiation (framework #3298 /
1997
+ * objectui #2693). At connect() we read `capabilities.transactionalBatch`
1998
+ * from discovery:
1999
+ * - Declared `true` → the backend GUARANTEES atomicity (declared ===
2000
+ * enforced). We TRUST it: any batch failure — including 404/405/501 —
2001
+ * surfaces as a real error. No non-atomic client-side compensation. This
2002
+ * is the path modern backends take.
2003
+ * - Declared `false`, or ABSENT (backend predates #3298) → we can't rely on
2004
+ * server atomicity, so we keep the legacy behaviour: on 404/405 (no
2005
+ * endpoint) or 501 (runtime without transactions) degrade to the
2006
+ * client-side, NON-atomic {@link emulateBatchTransaction} so a save is
2007
+ * still possible. Removing that here would regress older backends from
2008
+ * "saves, less safe" to "no save path" (#2679 compatibility constraint).
2009
+ * The non-atomic fallback stays isolated to THIS adapter.
1892
2010
  */
1893
2011
  async batchTransaction(operations) {
1894
- const url = `${this.baseUrl}/api/v1/batch`;
1895
- const response = await this.fetchImpl(url, {
1896
- method: "POST",
1897
- headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
1898
- // Send the session cookie too: in the browser console auth is cookie-based
1899
- // (no bearer `token`), so without `credentials: 'include'` this raw fetch
1900
- // is unauthenticated — every master-detail batch save would 401. Bearer
1901
- // (server-to-server) and cookie (console) auth now both work.
1902
- credentials: "include",
1903
- body: JSON.stringify({ operations })
1904
- });
1905
- if (!response.ok) {
1906
- const error = await response.json().catch(() => ({ message: response.statusText }));
1907
- throw new ObjectStackError(
1908
- error.error || error.message || `Batch failed with status ${response.status}`,
1909
- "BATCH_ERROR",
1910
- response.status
2012
+ await this.connect();
2013
+ const guaranteed = this.atomicBatchCapability === true;
2014
+ if (!guaranteed && this.batchUnsupported) {
2015
+ return emulateBatchTransaction(this, operations);
2016
+ }
2017
+ try {
2018
+ const payload = await this.client.data.batchTransaction(operations);
2019
+ this.emitBatchMutations(operations, payload?.results);
2020
+ return payload;
2021
+ } catch (err) {
2022
+ const status = this.errorStatusOf(err);
2023
+ if (!guaranteed && this.batchStatusUnsupported(status)) {
2024
+ return this.fallbackToEmulation(operations, status);
2025
+ }
2026
+ throw err;
2027
+ }
2028
+ }
2029
+ /** True for statuses that mean "this backend can't do a transactional batch". */
2030
+ batchStatusUnsupported(status) {
2031
+ return status === 404 || status === 405 || status === 501;
2032
+ }
2033
+ /** Best-effort HTTP status extraction from a thrown SDK/client error. */
2034
+ errorStatusOf(err) {
2035
+ if (!err || typeof err !== "object") return void 0;
2036
+ const e = err;
2037
+ const s = e.httpStatus ?? e.status ?? e.statusCode;
2038
+ return typeof s === "number" ? s : void 0;
2039
+ }
2040
+ /** Mark the endpoint unsupported (warn once) and serve via emulation. */
2041
+ fallbackToEmulation(operations, status) {
2042
+ if (!this.batchUnsupported) {
2043
+ this.batchUnsupported = true;
2044
+ console.warn(
2045
+ `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
2046
  );
1912
2047
  }
1913
- const payload = await response.json();
1914
- const results = Array.isArray(payload?.results) ? payload.results : [];
2048
+ return emulateBatchTransaction(this, operations);
2049
+ }
2050
+ /**
2051
+ * Emit one MutationEvent per committed operation so the invalidation bus
2052
+ * (#2269) sees writes that went through /batch exactly like single
2053
+ * create/update/delete calls — master-detail ModalForm saves otherwise leave
2054
+ * related lists and count badges stale (#2582). `results` is index-aligned
2055
+ * with `operations`; creates take id/record from the server echo.
2056
+ *
2057
+ * Only called on the server-committed paths. The emulation branch drives the
2058
+ * adapter's own create/update/delete primitives, which already emit — so it
2059
+ * must NOT be routed through here, or events would double-fire.
2060
+ */
2061
+ emitBatchMutations(operations, rawResults) {
2062
+ const results = Array.isArray(rawResults) ? rawResults : [];
1915
2063
  operations.forEach((op, i) => {
1916
2064
  const action = op.action ?? "create";
1917
2065
  const echo = results[i];
@@ -1923,7 +2071,6 @@ var ObjectStackAdapter = class {
1923
2071
  this.emitMutation({ type: "delete", resource: op.object, id: op.id });
1924
2072
  }
1925
2073
  });
1926
- return payload;
1927
2074
  }
1928
2075
  async bulk(resource, operation, data) {
1929
2076
  await this.connect();
@@ -2644,22 +2791,18 @@ var ObjectStackAdapter = class {
2644
2791
  */
2645
2792
  async updateViewConfig(objectName, viewId, config) {
2646
2793
  await this.connect();
2647
- try {
2648
- const merged = { ...config || {}, object: config?.object || objectName, name: viewId };
2649
- const result = await this.client.meta.saveItem(
2650
- "view",
2651
- viewId,
2652
- merged
2653
- );
2654
- const cacheKey = `view:${objectName}:${viewId}`;
2655
- this.metadataCache.invalidate?.(cacheKey);
2656
- this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
2657
- this.metadataCache.invalidate?.(`views:${objectName}`);
2658
- if (result && result.item) return result.item;
2659
- return result ?? void 0;
2660
- } catch (err) {
2661
- throw err;
2662
- }
2794
+ const merged = { ...config || {}, object: config?.object || objectName, name: viewId };
2795
+ const result = await this.client.meta.saveItem(
2796
+ "view",
2797
+ viewId,
2798
+ merged
2799
+ );
2800
+ const cacheKey = `view:${objectName}:${viewId}`;
2801
+ this.metadataCache.invalidate?.(cacheKey);
2802
+ this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
2803
+ this.metadataCache.invalidate?.(`views:${objectName}`);
2804
+ if (result && result.item) return result.item;
2805
+ return result ?? void 0;
2663
2806
  }
2664
2807
  /**
2665
2808
  * List user-created views for a given object via the metadata overlay
@@ -2671,11 +2814,17 @@ var ObjectStackAdapter = class {
2671
2814
  * Filters by `data.object === objectName` (or top-level `object`)
2672
2815
  * client-side because the metadata index is name-only, not field-typed.
2673
2816
  */
2674
- async listViews(objectName) {
2817
+ async listViews(objectName, options) {
2675
2818
  await this.connect();
2676
2819
  try {
2677
- const result = await this.client.meta.getItems("view");
2678
- const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
2820
+ let items;
2821
+ if (options?.previewDrafts) {
2822
+ const draftItems = await this.metadataClient().withPreviewDrafts(true).list("view");
2823
+ items = Array.isArray(draftItems) ? draftItems : [];
2824
+ } else {
2825
+ const result = await this.client.meta.getItems("view");
2826
+ items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
2827
+ }
2679
2828
  const FORM_FAMILY = /* @__PURE__ */ new Set(["form", "detail"]);
2680
2829
  return items.filter((v) => {
2681
2830
  if (!v) return false;
@@ -2686,21 +2835,36 @@ var ObjectStackAdapter = class {
2686
2835
  return !(viewKind && FORM_FAMILY.has(viewKind));
2687
2836
  }).map((v) => {
2688
2837
  const spec = v.list ?? v;
2838
+ const isDraft = v._draft === true || spec?._draft === true;
2689
2839
  if (spec && spec.config && typeof spec.config === "object") {
2690
2840
  return {
2691
2841
  ...spec.config,
2692
2842
  name: spec.name ?? spec.config.name,
2693
2843
  label: spec.label ?? spec.config.label,
2694
- isDefault: !!spec.isDefault
2844
+ isDefault: !!spec.isDefault,
2845
+ ...isDraft ? { _draft: true } : {}
2695
2846
  };
2696
2847
  }
2697
- return spec;
2848
+ return isDraft ? { ...spec, _draft: true } : spec;
2698
2849
  });
2699
2850
  } catch (err) {
2700
2851
  console.warn("[OBJECTSTACKDataSource] listViews failed:", err);
2701
2852
  return [];
2702
2853
  }
2703
2854
  }
2855
+ /**
2856
+ * Build a {@link MetadataClient} bound to this adapter's server + auth. Used
2857
+ * by draft-aware reads (`listViews({ previewDrafts })`) so the `/meta` route,
2858
+ * `?preview=draft` flag, and environment scoping live in the SDK rather than
2859
+ * being hand-assembled at each call site (#2767 P3).
2860
+ */
2861
+ metadataClient() {
2862
+ return new MetadataClient({
2863
+ baseUrl: this.baseUrl,
2864
+ fetch: this.fetchImpl,
2865
+ ...this.token ? { headers: { Authorization: `Bearer ${this.token}` } } : {}
2866
+ });
2867
+ }
2704
2868
  /**
2705
2869
  * List registered import `mapping` artifacts targeting a given object
2706
2870
  * (framework #2611). Reads the `mapping` metadata kind via the overlay API
@@ -2745,14 +2909,10 @@ var ObjectStackAdapter = class {
2745
2909
  object: spec?.object || objectName,
2746
2910
  data: spec?.data || { provider: "object", object: objectName }
2747
2911
  };
2748
- try {
2749
- const result = await this.client.meta.saveItem("view", name, fullSpec);
2750
- this.metadataCache.invalidate?.(`views:${objectName}`);
2751
- if (result && result.item) return result.item;
2752
- return fullSpec;
2753
- } catch (err) {
2754
- throw err;
2755
- }
2912
+ const result = await this.client.meta.saveItem("view", name, fullSpec);
2913
+ this.metadataCache.invalidate?.(`views:${objectName}`);
2914
+ if (result && result.item) return result.item;
2915
+ return fullSpec;
2756
2916
  }
2757
2917
  /**
2758
2918
  * Apply a partial update to an existing overlay view. Reads the current
@@ -2775,15 +2935,11 @@ var ObjectStackAdapter = class {
2775
2935
  name: viewName,
2776
2936
  object: current?.object || current?.data?.object || objectName
2777
2937
  };
2778
- try {
2779
- const result = await this.client.meta.saveItem("view", viewName, merged);
2780
- this.metadataCache.invalidate?.(`views:${objectName}`);
2781
- this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
2782
- if (result && result.item) return result.item;
2783
- return merged;
2784
- } catch (err) {
2785
- throw err;
2786
- }
2938
+ const result = await this.client.meta.saveItem("view", viewName, merged);
2939
+ this.metadataCache.invalidate?.(`views:${objectName}`);
2940
+ this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
2941
+ if (result && result.item) return result.item;
2942
+ return merged;
2787
2943
  }
2788
2944
  /**
2789
2945
  * Delete an overlay view (reset to artifact default if one exists, or
@@ -2792,14 +2948,10 @@ var ObjectStackAdapter = class {
2792
2948
  */
2793
2949
  async deleteView(objectName, viewName) {
2794
2950
  await this.connect();
2795
- try {
2796
- const result = await this.client.meta.deleteItem("view", viewName);
2797
- this.metadataCache.invalidate?.(`views:${objectName}`);
2798
- this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
2799
- return { deleted: !!(result?.deleted ?? result?.reset ?? true) };
2800
- } catch (err) {
2801
- throw err;
2802
- }
2951
+ const result = await this.client.meta.deleteItem("view", viewName);
2952
+ this.metadataCache.invalidate?.(`views:${objectName}`);
2953
+ this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
2954
+ return { deleted: !!(result?.deleted ?? result?.reset ?? true) };
2803
2955
  }
2804
2956
  /**
2805
2957
  * Get an application definition by name or ID.
@@ -2855,19 +3007,15 @@ var ObjectStackAdapter = class {
2855
3007
  */
2856
3008
  async updateDashboard(dashboardName, schema) {
2857
3009
  await this.connect();
2858
- try {
2859
- const result = await this.client.meta.saveItem(
2860
- "dashboard",
2861
- dashboardName,
2862
- schema
2863
- );
2864
- this.metadataCache.invalidate?.("dashboards");
2865
- this.metadataCache.invalidate?.(`dashboard:${dashboardName}`);
2866
- if (result && result.item) return result.item;
2867
- return result ?? void 0;
2868
- } catch (err) {
2869
- throw err;
2870
- }
3010
+ const result = await this.client.meta.saveItem(
3011
+ "dashboard",
3012
+ dashboardName,
3013
+ schema
3014
+ );
3015
+ this.metadataCache.invalidate?.("dashboards");
3016
+ this.metadataCache.invalidate?.(`dashboard:${dashboardName}`);
3017
+ if (result && result.item) return result.item;
3018
+ return result ?? void 0;
2871
3019
  }
2872
3020
  /**
2873
3021
  * Perform server-side aggregation via the ObjectStack analytics API.
@@ -3238,6 +3386,7 @@ export {
3238
3386
  isErrorType,
3239
3387
  isObjectStackError,
3240
3388
  normaliseClientError,
3389
+ readTransactionalBatchCapability,
3241
3390
  snapToGrid,
3242
3391
  validatePluginContract
3243
3392
  };