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