@object-ui/data-objectstack 16.0.0 → 17.1.0

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