@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.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/errors.ts
323
- var ObjectStackError = class extends Error {
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
- * Create a new ObjectStackError
326
- *
327
- * @param message - Human-readable error message
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
- constructor(message, code, statusCode, details) {
333
- super(message);
334
- __publicField(this, "code", code);
335
- __publicField(this, "statusCode", statusCode);
336
- __publicField(this, "details", details);
337
- this.name = "ObjectStackError";
338
- if (Error.captureStackTrace) {
339
- Error.captureStackTrace(this, this.constructor);
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
- * Convert error to JSON for logging/debugging
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
- toJSON() {
346
- return {
347
- name: this.name,
348
- message: this.message,
349
- code: this.code,
350
- statusCode: this.statusCode,
351
- details: this.details,
352
- stack: this.stack
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
- var MetadataNotFoundError = class extends ObjectStackError {
357
- constructor(objectName, details) {
358
- super(
359
- `Metadata not found for object: ${objectName}`,
360
- "METADATA_NOT_FOUND",
361
- 404,
362
- { objectName, ...details }
363
- );
364
- this.name = "MetadataNotFoundError";
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
- * Create a new BulkOperationError
370
- *
371
- * @param operation - The bulk operation that failed (create, update, delete)
372
- * @param successCount - Number of successful operations
373
- * @param failureCount - Number of failed operations
374
- * @param errors - Array of individual errors
375
- * @param details - Additional error details
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
- constructor(operation, successCount, failureCount, errors, details) {
378
- super(
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
- * Get a summary of the bulk operation failure
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
- getSummary() {
399
- const total = this.successCount + this.failureCount;
400
- const failureRate = total > 0 ? this.failureCount / total : 0;
401
- return {
402
- operation: this.details?.operation,
403
- total,
404
- successful: this.successCount,
405
- failed: this.failureCount,
406
- failureRate,
407
- errors: this.errors
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
- * Create a new ValidationError
437
- *
438
- * @param message - Human-readable error message
439
- * @param field - The field that failed validation (optional)
440
- * @param validationErrors - Array of validation error details
441
- * @param details - Additional error details
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
- constructor(message, field, validationErrors, details) {
444
- super(
445
- message,
446
- "VALIDATION_ERROR",
447
- 400,
448
- {
449
- field,
450
- validationErrors,
451
- ...details
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 all validation errors as a formatted list
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
- getValidationErrors() {
462
- return this.validationErrors || [];
463
- }
464
- };
465
- function createErrorFromResponse(response, context) {
466
- const status = response?.status || response?.statusCode || 500;
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
- switch (status) {
477
- case 401:
478
- return new AuthenticationError(message, details, 401);
479
- case 403:
480
- return new AuthenticationError(message, details, 403);
481
- case 404:
482
- if (context?.includes("metadata") || context?.includes("schema") || context?.includes("getObjectSchema")) {
483
- const objectName = extractObjectName(context);
484
- return new MetadataNotFoundError(objectName, details);
485
- }
486
- return new ObjectStackError(message, "NOT_FOUND", 404, details);
487
- case 400:
488
- return new ValidationError(message, void 0, response?.data?.errors, details);
489
- case 503:
490
- return new ConnectionError(message, response?.config?.url, details, 503);
491
- case 504:
492
- return new ConnectionError(message, response?.config?.url, details, 504);
493
- default:
494
- return new ObjectStackError(message, "UNKNOWN_ERROR", status, details);
495
- }
496
- }
497
- function extractObjectName(context) {
498
- if (!context) return "unknown";
499
- const match = context.match(/\(([^)]+)\)/);
500
- return match ? match[1] : "unknown";
501
- }
502
- function isObjectStackError(error) {
503
- return error instanceof ObjectStackError;
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
- * Deploy an application to the cloud.
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 deploy(appId, config) {
518
- const client = this.getClient();
519
- const result = await client.cloud?.deploy?.(appId, config);
520
- return result ?? { deploymentId: `deploy-${Date.now()}`, status: "pending" };
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
- * Get deployment status.
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 getDeploymentStatus(deploymentId) {
526
- const client = this.getClient();
527
- const result = await client.cloud?.getDeployment?.(deploymentId);
528
- return result ?? { status: "unknown" };
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
- * Search marketplace entries.
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 searchMarketplace(query, category) {
534
- const client = this.getClient();
535
- const result = await client.cloud?.marketplace?.search?.({ query, category });
536
- return result?.items ?? [];
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
- * Install a marketplace plugin.
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 installPlugin(pluginId) {
542
- const client = this.getClient();
543
- const result = await client.cloud?.marketplace?.install?.(pluginId);
544
- return result ?? { success: false };
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
- // src/contracts.ts
549
- function validatePluginContract(contract) {
550
- const errors = [];
551
- const warnings = [];
552
- if (!contract.name || contract.name.trim().length === 0) {
553
- errors.push({ field: "name", message: "Plugin name is required", code: "MISSING_NAME" });
554
- }
555
- if (!contract.version || !/^\d+\.\d+\.\d+/.test(contract.version)) {
556
- errors.push({ field: "version", message: "Valid semver version is required", code: "INVALID_VERSION" });
557
- }
558
- if (!contract.exports || contract.exports.length === 0) {
559
- errors.push({ field: "exports", message: "At least one export is required", code: "NO_EXPORTS" });
560
- }
561
- if (contract.exports) {
562
- const validTypes = ["component", "hook", "utility", "provider"];
563
- for (const exp of contract.exports) {
564
- if (!exp.name) {
565
- errors.push({ field: "exports.name", message: "Export name is required", code: "MISSING_EXPORT_NAME" });
566
- }
567
- if (!validTypes.includes(exp.type)) {
568
- errors.push({ field: "exports.type", message: `Invalid export type: ${exp.type}`, code: "INVALID_EXPORT_TYPE" });
569
- }
570
- }
571
- }
572
- if (!contract.permissions || contract.permissions.length === 0) {
573
- warnings.push("No permissions declared \u2014 plugin will have minimal access");
574
- }
575
- return { valid: errors.length === 0, errors, warnings };
576
- }
577
- function generateContractManifest(contract) {
578
- return {
579
- $schema: "https://objectui.org/schemas/plugin-contract-v1.json",
580
- name: contract.name,
581
- version: contract.version,
582
- peerDependencies: contract.peerDependencies ?? {},
583
- exports: contract.exports.map((exp) => ({
584
- name: exp.name,
585
- type: exp.type,
586
- description: exp.description ?? ""
587
- })),
588
- permissions: contract.permissions ?? [],
589
- api: contract.api ?? {},
590
- generatedAt: (/* @__PURE__ */ new Date()).toISOString()
591
- };
592
- }
593
-
594
- // src/userState.ts
595
- var DEFAULT_RESOURCE = "sys_user_preference";
596
- function createObjectStackUserStateAdapter(options) {
597
- const {
598
- dataSource,
599
- userId,
600
- key,
601
- resource = DEFAULT_RESOURCE,
602
- onError = () => {
603
- }
604
- } = options;
605
- let cachedRowId = null;
606
- let saveChain = Promise.resolve();
607
- const findExisting = async () => {
608
- const params = {
609
- $filter: {
610
- user_id: userId,
611
- key
612
- },
613
- $top: 1
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
- const result = await dataSource.find(resource, params);
616
- const rows = result?.data ?? [];
617
- const match = rows.find((r) => {
618
- if (!r) return false;
619
- const userMismatch = r.user_id !== void 0 && r.user_id !== userId;
620
- const keyMismatch = r.key !== void 0 && r.key !== key;
621
- return !userMismatch && !keyMismatch;
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
- return match ?? null;
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
- * Register a new integration.
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
- register(id, config) {
707
- this.integrations.set(id, config);
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
- * Remove an integration.
711
- */
712
- unregister(id) {
713
- this.integrations.delete(id);
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
- * Get all registered integrations.
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
- getAll() {
719
- return new Map(this.integrations);
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
- * Get integrations that match a specific event.
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
- getForEvent(event) {
725
- return Array.from(this.integrations.values()).filter(
726
- (integration) => integration.enabled && integration.triggers?.some((t) => t.event === event)
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
- * Dispatch an event to all matching integrations.
731
- * Returns results for each integration.
818
+ * Convert error to JSON for logging/debugging
732
819
  */
733
- async dispatch(event, payload) {
734
- const matching = this.getForEvent(event);
735
- const results = [];
736
- for (const [id, integration] of this.integrations) {
737
- if (!matching.includes(integration)) continue;
738
- try {
739
- await this.send(integration, payload);
740
- results.push({ id, success: true });
741
- } catch (err) {
742
- results.push({ id, success: false, error: err.message });
743
- }
744
- }
745
- return results;
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
- * Send payload to a specific integration.
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
- async send(integration, payload) {
751
- switch (integration.provider) {
752
- case "webhook": {
753
- const cfg = integration.config;
754
- const url = cfg.url;
755
- try {
756
- const parsed = new URL(url);
757
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
758
- throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
759
- }
760
- } catch (e) {
761
- if (e instanceof TypeError) {
762
- throw new Error(`Invalid webhook URL: ${url}`);
763
- }
764
- throw e;
765
- }
766
- await fetch(url, {
767
- method: cfg.method,
768
- headers: {
769
- "Content-Type": "application/json",
770
- ...cfg.headers
771
- },
772
- body: JSON.stringify(payload)
773
- });
774
- break;
775
- }
776
- case "slack": {
777
- const cfg = integration.config;
778
- const url = cfg.webhookUrl;
779
- try {
780
- const parsed = new URL(url);
781
- if (parsed.protocol !== "https:") {
782
- throw new Error(`Invalid Slack webhook URL protocol: ${parsed.protocol}`);
783
- }
784
- } catch (e) {
785
- if (e instanceof TypeError) {
786
- throw new Error(`Invalid Slack webhook URL: ${url}`);
787
- }
788
- throw e;
789
- }
790
- await fetch(url, {
791
- method: "POST",
792
- headers: { "Content-Type": "application/json" },
793
- body: JSON.stringify({
794
- channel: cfg.channel,
795
- username: cfg.username,
796
- icon_emoji: cfg.iconEmoji,
797
- text: JSON.stringify(payload)
798
- })
799
- });
800
- break;
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
- // Email and other providers would require server-side implementation
803
- default:
804
- break;
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
- * Derive a client whose reads render the draft-overlaid world (or not).
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
- withPreviewDrafts(previewDrafts) {
870
- if (previewDrafts === this.previewDrafts) return this;
871
- const envMatch = this.base.match(/\/api\/v\d+\/environments\/([^/]+)\/meta$/);
872
- return new _MetadataClient({
873
- baseUrl: this.base.replace(/\/api\/v\d+(?:\/environments\/[^/]+)?\/meta$/, "").replace(/\/+$/, ""),
874
- ...envMatch ? { environmentId: decodeURIComponent(envMatch[1]) } : {},
875
- fetch: this.fetchImpl,
876
- headers: this.headers,
877
- previewDrafts
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
- /** List all registered metadata types (returns the registry rows). */
881
- async listTypes() {
882
- const res = await this.fetchImpl(this.base, { method: "GET", headers: this.headers, cache: "no-store" });
883
- if (!res.ok) throw await parseError(res);
884
- const data = await res.json();
885
- if (Array.isArray(data)) return data;
886
- if (data && Array.isArray(data.entries)) return data.entries;
887
- if (data && Array.isArray(data.items)) return data.items;
888
- if (data && Array.isArray(data.types)) {
889
- return data.types.map((t) => ({ type: t }));
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
- /** List items of a metadata type (e.g. `object`, `field`, `view`). */
894
- async list(type, options = {}) {
895
- const params = [];
896
- if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
897
- if (this.previewDrafts) params.push("preview=draft");
898
- const qs = params.length ? `?${params.join("&")}` : "";
899
- const url = `${this.base}/${encodeURIComponent(type)}${qs}`;
900
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
901
- if (!res.ok) throw await parseError(res);
902
- const data = await res.json();
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
- * List pending DRAFT items (ADR-0033) — what an AI authored but nobody
911
- * published yet. `list()` only sees published/active metadata, so a
912
- * just-built app package looks empty there; this surfaces the drafts so the
913
- * console can show a "pending changes" view and draft-aware package contents.
914
- * Optionally narrow by `packageId` and/or `type`. Returns light headers
915
- * (no body) carrying `packageId` for grouping.
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
- async listDrafts(options = {}) {
918
- const params = [];
919
- if (options.packageId) params.push(`packageId=${encodeURIComponent(options.packageId)}`);
920
- if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
921
- const qs = params.length ? `?${params.join("&")}` : "";
922
- const url = `${this.base}/_drafts${qs}`;
923
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
924
- if (!res.ok) throw await parseError(res);
925
- const data = await res.json();
926
- if (Array.isArray(data)) return data;
927
- return data?.drafts ?? data?.data?.drafts ?? [];
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 a single metadata item. Returns the unwrapped item content
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
- async get(type, name, options = {}) {
935
- const params = [];
936
- if (options.state === "draft") params.push("state=draft");
937
- else if (this.previewDrafts) params.push("preview=draft");
938
- if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
939
- const qs = params.length ? `?${params.join("&")}` : "";
940
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
941
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
942
- if (res.status === 404) return null;
943
- if (!res.ok) throw await parseError(res);
944
- return await res.json();
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
- * Read the pending draft body for an item (`?state=draft`). Returns
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 getDraft(type, name, options = {}) {
959
- return this.get(type, name, { state: "draft", ...options.packageId ? { packageId: options.packageId } : {} });
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
- * Save (PUT) a metadata item. The framework accepts both the bare
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 save(type, name, item, options = {}) {
968
- const params = [];
969
- if (options.force) params.push("force=true");
970
- if (options.mode === "draft") params.push("mode=draft");
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
- * Publish a single pending draft BY REFERENCE — promotes the draft row for
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 publishDraft(type, name) {
1001
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
1002
- const res = await this.fetchImpl(url, {
1003
- method: "POST",
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
- * Get the 3-state layered view of a metadata item (Phase 3a). Returns
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 layered(type, name, options = {}) {
1018
- const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : "";
1019
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
1020
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
1021
- if (res.status === 404) {
1022
- return { code: null, overlay: null, overlayScope: null, effective: null };
1023
- }
1024
- if (!res.ok) throw await parseError(res);
1025
- const body = await res.json();
1026
- const hasEnvelope = body && ("code" in body || "overlay" in body || "effective" in body);
1027
- if (!hasEnvelope) {
1028
- return {
1029
- code: null,
1030
- overlay: null,
1031
- overlayScope: null,
1032
- effective: body
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
- * Cross-type sweep of load-time validation results — calls
1057
- * `GET /meta/diagnostics`. Returns every entry the framework
1058
- * considers invalid (or, when `severity: 'warning'`, also entries
1059
- * with only warnings).
1060
- *
1061
- * Used by the Studio's governance overview page and by the
1062
- * directory page to show "N invalid" badges per metadata type.
1063
- */
1064
- async diagnostics(options = {}) {
1065
- const params = [];
1066
- if (options.type) params.push(`type=${encodeURIComponent(options.type)}`);
1067
- if (options.severity) params.push(`severity=${encodeURIComponent(options.severity)}`);
1068
- if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
1069
- const qs = params.length ? `?${params.join("&")}` : "";
1070
- const url = `${this.base}/diagnostics${qs}`;
1071
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
1072
- if (res.status === 404) {
1073
- return { entries: [], total: 0, scannedTypes: 0, scannedItems: 0, stats: {} };
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
- if (!res.ok) throw await parseError(res);
1076
- const data = await res.json();
1077
- return {
1078
- entries: Array.isArray(data.entries) ? data.entries : [],
1079
- total: typeof data.total === "number" ? data.total : 0,
1080
- scannedTypes: typeof data.scannedTypes === "number" ? data.scannedTypes : 0,
1081
- scannedItems: typeof data.scannedItems === "number" ? data.scannedItems : 0,
1082
- stats: data.stats && typeof data.stats === "object" ? data.stats : {}
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
- * Find every metadata item that references this one (Phase 3a). Useful
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
- async references(type, name) {
1091
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`;
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
- * Reset a metadata customization overlay back to the artifact default.
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
- async reset(type, name, options = {}) {
1105
- const qs = options.state === "draft" ? "?state=draft" : "";
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
- * Promote the pending draft of an item to the active overlay
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
- async publish(type, name, options = {}) {
1122
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
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
- * Restore an item to a previous version (POST `/meta/:type/:name/rollback`).
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
- async rollback(type, name, toVersion, options = {}) {
1145
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`;
1146
- const headers = {
1147
- ...this.headers,
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
- * Compute a structured top-level key diff between two history versions
1164
- * (GET `/meta/:type/:name/diff?from=&to=`). Returns
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 diff(type, name, fromVersion, toVersion) {
1170
- const params = new URLSearchParams();
1171
- if (fromVersion !== void 0) params.set("from", String(fromVersion));
1172
- if (toVersion !== void 0) params.set("to", String(toVersion));
1173
- const qs = params.toString();
1174
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ""}`;
1175
- const res = await this.fetchImpl(url, {
1176
- method: "GET",
1177
- headers: this.headers,
1178
- cache: "no-store"
1179
- });
1180
- if (!res.ok) throw await parseError(res);
1181
- return await res.json();
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
- * Fetch the protection-audit trail for one metadata item
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 audit(type, name, options = {}) {
1202
- const params = new URLSearchParams();
1203
- if (options.limit !== void 0) params.set("limit", String(options.limit));
1204
- const qs = params.toString();
1205
- const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs ? `?${qs}` : ""}`;
1206
- const res = await this.fetchImpl(url, { method: "GET", headers: this.headers, cache: "no-store" });
1207
- if (!res.ok) throw await parseError(res);
1208
- return await res.json();
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). Runs the operations
1937
- * in ONE server transaction — commit all or roll back all. A field value of
1938
- * `{ $ref: <earlier op index> }` resolves to that op's created id, so a child
1939
- * can reference its parent created earlier in the same batch (master-detail).
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
- const url = `${this.baseUrl}/api/v1/batch`;
1943
- const response = await this.fetchImpl(url, {
1944
- method: "POST",
1945
- headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
1946
- // Send the session cookie too: in the browser console auth is cookie-based
1947
- // (no bearer `token`), so without `credentials: 'include'` this raw fetch
1948
- // is unauthenticated — every master-detail batch save would 401. Bearer
1949
- // (server-to-server) and cookie (console) auth now both work.
1950
- credentials: "include",
1951
- body: JSON.stringify({ operations })
1952
- });
1953
- if (!response.ok) {
1954
- const error = await response.json().catch(() => ({ message: response.statusText }));
1955
- throw new ObjectStackError(
1956
- error.error || error.message || `Batch failed with status ${response.status}`,
1957
- "BATCH_ERROR",
1958
- response.status
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
- const payload = await response.json();
1962
- const results = Array.isArray(payload?.results) ? payload.results : [];
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
- try {
2696
- const merged = { ...config || {}, object: config?.object || objectName, name: viewId };
2697
- const result = await this.client.meta.saveItem(
2698
- "view",
2699
- viewId,
2700
- merged
2701
- );
2702
- const cacheKey = `view:${objectName}:${viewId}`;
2703
- this.metadataCache.invalidate?.(cacheKey);
2704
- this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
2705
- this.metadataCache.invalidate?.(`views:${objectName}`);
2706
- if (result && result.item) return result.item;
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
- const result = await this.client.meta.getItems("view");
2726
- const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
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
- try {
2797
- const result = await this.client.meta.saveItem("view", name, fullSpec);
2798
- this.metadataCache.invalidate?.(`views:${objectName}`);
2799
- if (result && result.item) return result.item;
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
- try {
2827
- const result = await this.client.meta.saveItem("view", viewName, merged);
2828
- this.metadataCache.invalidate?.(`views:${objectName}`);
2829
- this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
2830
- if (result && result.item) return result.item;
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
- try {
2844
- const result = await this.client.meta.deleteItem("view", viewName);
2845
- this.metadataCache.invalidate?.(`views:${objectName}`);
2846
- this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
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
- try {
2907
- const result = await this.client.meta.saveItem(
2908
- "dashboard",
2909
- dashboardName,
2910
- schema
2911
- );
2912
- this.metadataCache.invalidate?.("dashboards");
2913
- this.metadataCache.invalidate?.(`dashboard:${dashboardName}`);
2914
- if (result && result.item) return result.item;
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
  });