@object-ui/data-objectstack 16.1.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
  }
@@ -1061,10 +1084,9 @@ function createObjectStackUserStateAdapter(options) {
1061
1084
  return [];
1062
1085
  };
1063
1086
  const upsert = async (items) => {
1064
- const now = (/* @__PURE__ */ new Date()).toISOString();
1065
1087
  if (cachedRowId !== null) {
1066
1088
  try {
1067
- await dataSource.update(resource, cachedRowId, { value: items, updated_at: now });
1089
+ await dataSource.update(resource, cachedRowId, { value: items });
1068
1090
  return;
1069
1091
  } catch (updateError) {
1070
1092
  cachedRowId = null;
@@ -1074,15 +1096,14 @@ function createObjectStackUserStateAdapter(options) {
1074
1096
  const existing = await findExisting();
1075
1097
  if (existing && existing.id !== void 0 && existing.id !== null) {
1076
1098
  cachedRowId = existing.id;
1077
- await dataSource.update(resource, existing.id, { value: items, updated_at: now });
1099
+ await dataSource.update(resource, existing.id, { value: items });
1078
1100
  return;
1079
1101
  }
1080
1102
  try {
1081
1103
  const created = await dataSource.create(resource, {
1082
1104
  user_id: userId,
1083
1105
  key,
1084
- value: items,
1085
- updated_at: now
1106
+ value: items
1086
1107
  });
1087
1108
  const newId = created?.id;
1088
1109
  if (newId !== void 0 && newId !== null) cachedRowId = newId;
@@ -1090,7 +1111,7 @@ function createObjectStackUserStateAdapter(options) {
1090
1111
  const recovered = await findExisting();
1091
1112
  if (recovered && recovered.id !== void 0 && recovered.id !== null) {
1092
1113
  cachedRowId = recovered.id;
1093
- await dataSource.update(resource, recovered.id, { value: items, updated_at: now });
1114
+ await dataSource.update(resource, recovered.id, { value: items });
1094
1115
  return;
1095
1116
  }
1096
1117
  throw createError;
@@ -1419,38 +1440,107 @@ var FILTER_OPERATOR_ALIASES = {
1419
1440
  is_null: "isnull",
1420
1441
  isnull: "isnull",
1421
1442
  is_not_null: "isnotnull",
1422
- 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: ">"
1423
1453
  };
1424
1454
  function normalizeFilterOperator(op) {
1425
1455
  if (typeof op !== "string") return null;
1426
1456
  const lower = op.toLowerCase();
1427
1457
  return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
1428
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
+ }
1429
1479
  function objectFilterEntryToAST(entry) {
1430
1480
  if (!entry || typeof entry !== "object") return null;
1431
- const field = entry.field ?? entry.name;
1481
+ const field = entry.field;
1432
1482
  const rawOp = entry.operator ?? entry.op ?? "=";
1433
1483
  const op = normalizeFilterOperator(rawOp);
1434
1484
  if (!field || !op) return null;
1435
1485
  return [String(field), op, entry.value];
1436
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
+ }
1437
1518
  function translateFilterToAST(filter) {
1438
1519
  if (filter === void 0 || filter === null) return void 0;
1439
1520
  if (Array.isArray(filter)) {
1440
1521
  if (filter.length === 0) return void 0;
1441
- const first = filter[0];
1442
- const isObjectForm = filter.length > 0 && typeof first === "object" && first !== null && !Array.isArray(first) && first.field !== void 0;
1443
- if (isObjectForm) {
1444
- const tuples = filter.map((entry) => objectFilterEntryToAST(entry)).filter((t) => t !== null);
1445
- if (tuples.length === 0) return void 0;
1446
- if (tuples.length === 1) return tuples[0];
1447
- return ["and", ...tuples];
1448
- }
1449
- return filter;
1522
+ return translateFilterArray(filter);
1450
1523
  }
1451
1524
  if (typeof filter === "object") {
1452
1525
  if (Object.keys(filter).length === 0) return void 0;
1453
- 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;
1454
1544
  }
1455
1545
  return void 0;
1456
1546
  }
@@ -1486,7 +1576,46 @@ function is404Error(error) {
1486
1576
  return true;
1487
1577
  }
1488
1578
  const code = typeof err.code === "string" ? err.code : "";
1489
- 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 };
1490
1619
  }
1491
1620
  var ConcurrentUpdateError = class extends Error {
1492
1621
  constructor(opts) {
@@ -1508,9 +1637,25 @@ function isConcurrentUpdateError(error) {
1508
1637
  function normaliseClientError(error) {
1509
1638
  if (!error || typeof error !== "object") return error;
1510
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
+ }
1511
1657
  if (e.code !== "CONCURRENT_UPDATE" && e.httpStatus !== 409) return error;
1512
1658
  if (e.code !== "CONCURRENT_UPDATE") return error;
1513
- const details = e.details ?? {};
1514
1659
  return new ConcurrentUpdateError({
1515
1660
  currentVersion: typeof details.currentVersion === "string" ? details.currentVersion : null,
1516
1661
  currentRecord: details.currentRecord ?? null,
@@ -1524,7 +1669,7 @@ function createQuietHttpLogger() {
1524
1669
  const errBody = meta.error;
1525
1670
  if (errBody && typeof errBody === "object") {
1526
1671
  const code = errBody.code;
1527
- if (code === "object_not_found" || code === "record_not_found") return true;
1672
+ if (errorCodeIsAnyOf({ code }, ["OBJECT_NOT_FOUND", "RECORD_NOT_FOUND"])) return true;
1528
1673
  }
1529
1674
  return false;
1530
1675
  };
@@ -1568,6 +1713,8 @@ var ObjectStackAdapter = class {
1568
1713
  __publicField(this, "reconnectAttempts", 0);
1569
1714
  __publicField(this, "baseUrl");
1570
1715
  __publicField(this, "token");
1716
+ /** One "analytics capability is missing" console line per adapter, not per widget. */
1717
+ __publicField(this, "analyticsCapabilityWarned", false);
1571
1718
  __publicField(this, "fetchImpl");
1572
1719
  // In-flight find() requests keyed by resource + serialized params.
1573
1720
  // Coalesces concurrent identical reads (e.g. React StrictMode double-mount,
@@ -1599,6 +1746,10 @@ var ObjectStackAdapter = class {
1599
1746
  // calendar) auto-refresh — the interface ListView relies on to reflect
1600
1747
  // inline-edit "Save All" writes without a manual reload.
1601
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());
1602
1753
  this.client = new ObjectStackClient({ ...config, logger: createQuietHttpLogger() });
1603
1754
  this.metadataCache = new MetadataCache(config.cache);
1604
1755
  this.autoReconnect = config.autoReconnect ?? true;
@@ -1770,6 +1921,77 @@ var ObjectStackAdapter = class {
1770
1921
  promise.then(cleanup, cleanup);
1771
1922
  return promise;
1772
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
+ }
1773
1995
  /**
1774
1996
  * Find a single record by ID.
1775
1997
  */
@@ -1833,11 +2055,84 @@ var ObjectStackAdapter = class {
1833
2055
  this.mutationListeners.delete(callback);
1834
2056
  };
1835
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
+ }
1836
2126
  async create(resource, data) {
1837
2127
  await this.connect();
1838
- const result = await this.client.data.create(resource, data);
1839
- this.emitMutation({ type: "create", resource, record: { ...result.record } });
1840
- 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
+ }
1841
2136
  }
1842
2137
  /**
1843
2138
  * Update an existing record.
@@ -1860,6 +2155,7 @@ var ObjectStackAdapter = class {
1860
2155
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
1861
2156
  );
1862
2157
  this.emitMutation({ type: "update", resource, id, record: { ...result.record } });
2158
+ this.notifyDroppedFields("update", resource, result, id);
1863
2159
  return result.record;
1864
2160
  } catch (err) {
1865
2161
  throw normaliseClientError(err);
@@ -2017,6 +2313,7 @@ var ObjectStackAdapter = class {
2017
2313
  try {
2018
2314
  const payload = await this.client.data.batchTransaction(operations);
2019
2315
  this.emitBatchMutations(operations, payload?.results);
2316
+ this.notifyBatchDroppedFields(operations, payload);
2020
2317
  return payload;
2021
2318
  } catch (err) {
2022
2319
  const status = this.errorStatusOf(err);
@@ -2048,7 +2345,7 @@ var ObjectStackAdapter = class {
2048
2345
  return emulateBatchTransaction(this, operations);
2049
2346
  }
2050
2347
  /**
2051
- * Emit one MutationEvent per committed operation so the invalidation bus
2348
+ * Emit one DataSourceMutationEvent per committed operation so the invalidation bus
2052
2349
  * (#2269) sees writes that went through /batch exactly like single
2053
2350
  * create/update/delete calls — master-detail ModalForm saves otherwise leave
2054
2351
  * related lists and count badges stale (#2582). `results` is index-aligned
@@ -2401,20 +2698,8 @@ var ObjectStackAdapter = class {
2401
2698
  const selectFields = params.$select.includes("id") ? params.$select : ["id", ...params.$select];
2402
2699
  queryParams.set("select", selectFields.join(","));
2403
2700
  }
2404
- if (params.$orderby) {
2405
- if (Array.isArray(params.$orderby)) {
2406
- const sortStr = params.$orderby.map((item) => {
2407
- if (typeof item === "string") return item;
2408
- const field = item.field;
2409
- const order = item.order || "asc";
2410
- return order === "desc" ? `-${field}` : field;
2411
- }).join(",");
2412
- queryParams.set("sort", sortStr);
2413
- } else {
2414
- const sortStr = Object.entries(params.$orderby).map(([field, order]) => order === "desc" ? `-${field}` : field).join(",");
2415
- queryParams.set("sort", sortStr);
2416
- }
2417
- }
2701
+ const sortStr = serializeOrderBy(params.$orderby);
2702
+ if (sortStr) queryParams.set("sort", sortStr);
2418
2703
  if (params.$filter !== void 0 && params.$filter !== null) {
2419
2704
  const translated = translateFilterToAST(params.$filter);
2420
2705
  if (translated !== void 0) {
@@ -2478,6 +2763,13 @@ var ObjectStackAdapter = class {
2478
2763
  queryParams.set("filter", JSON.stringify(translated));
2479
2764
  }
2480
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
+ }
2481
2773
  const baseUrl = this.baseUrl.replace(/\/$/, "");
2482
2774
  const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
2483
2775
  const dataPath = hasApiVersionSuffix ? "/data" : "/api/v1/data";
@@ -2506,38 +2798,14 @@ var ObjectStackAdapter = class {
2506
2798
  const isEmpty = Array.isArray(params.$filter) ? params.$filter.length === 0 : typeof params.$filter === "object" && Object.keys(params.$filter).length === 0;
2507
2799
  if (!isEmpty) {
2508
2800
  if (Array.isArray(params.$filter)) {
2509
- const isObjectForm = params.$filter.length > 0 && typeof params.$filter[0] === "object" && !Array.isArray(params.$filter[0]) && params.$filter[0].field !== void 0;
2510
- if (isObjectForm) {
2511
- const tuples = params.$filter.map((entry) => objectFilterEntryToAST(entry)).filter((t) => t !== null);
2512
- if (tuples.length === 0) {
2513
- } else if (tuples.length === 1) {
2514
- options.filters = tuples[0];
2515
- } else {
2516
- options.filters = ["and", ...tuples];
2517
- }
2518
- } else {
2519
- options.filters = params.$filter;
2520
- }
2801
+ options.filters = translateFilterArray(params.$filter);
2521
2802
  } else {
2522
2803
  options.filters = convertFiltersToAST(params.$filter);
2523
2804
  }
2524
2805
  }
2525
2806
  }
2526
- if (params.$orderby) {
2527
- if (Array.isArray(params.$orderby)) {
2528
- options.sort = params.$orderby.map((item) => {
2529
- if (typeof item === "string") return item;
2530
- const field = item.field;
2531
- const order = item.order || "asc";
2532
- return order === "desc" ? `-${field}` : field;
2533
- });
2534
- } else {
2535
- const sortArray = Object.entries(params.$orderby).map(([field, order]) => {
2536
- return order === "desc" ? `-${field}` : field;
2537
- });
2538
- options.sort = sortArray;
2539
- }
2540
- }
2807
+ const sort = serializeOrderBy(params.$orderby);
2808
+ if (sort) options.sort = sort;
2541
2809
  if (params.$skip !== void 0) {
2542
2810
  options.skip = params.$skip;
2543
2811
  }
@@ -3041,6 +3309,7 @@ var ObjectStackAdapter = class {
3041
3309
  }
3042
3310
  try {
3043
3311
  const measureName = params.function === "count" ? "count" : `${params.field}_${params.function}`;
3312
+ const valueKey = this.aggregateValueKey(params);
3044
3313
  const payload = {
3045
3314
  cube: resource,
3046
3315
  measures: [measureName],
@@ -3055,30 +3324,62 @@ var ObjectStackAdapter = class {
3055
3324
  const measureMissing = rawRows.length > 0 && rawRows.every((row) => {
3056
3325
  if (row == null) return true;
3057
3326
  if (measureName in row && row[measureName] != null) return false;
3058
- if (params.field in row && row[params.field] != null) return false;
3327
+ if (valueKey in row && row[valueKey] != null) return false;
3059
3328
  return true;
3060
3329
  });
3061
3330
  if (measureMissing) {
3062
- const result = await this.find(resource, params.filter ? { $filter: params.filter } : void 0);
3063
- const records = result.data || [];
3064
- if (records.length === 0) return [];
3065
- return this.aggregateClientSide(records, params);
3331
+ return await this.aggregateViaFind(resource, params);
3066
3332
  }
3067
3333
  return rawRows.map((row) => {
3068
3334
  const mapped = { ...row };
3069
- if (measureName !== params.field && measureName in mapped) {
3070
- mapped[params.field] = mapped[measureName];
3335
+ if (measureName !== valueKey && measureName in mapped) {
3336
+ mapped[valueKey] = mapped[measureName];
3071
3337
  delete mapped[measureName];
3072
3338
  }
3073
3339
  return mapped;
3074
3340
  });
3075
- } catch {
3076
- const result = await this.find(resource, params.filter ? { $filter: params.filter } : void 0);
3077
- const records = result.data || [];
3078
- if (records.length === 0) return [];
3079
- 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);
3080
3350
  }
3081
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
+ }
3082
3383
  /**
3083
3384
  * Run a semantic-layer `dataset` (ADR-0021) and return chart-ready rows.
3084
3385
  *
@@ -3117,6 +3418,9 @@ var ObjectStackAdapter = class {
3117
3418
  detail = errBody?.message || errBody?.error || JSON.stringify(errBody);
3118
3419
  } catch {
3119
3420
  }
3421
+ if (res.status === 501 || res.status === 404) {
3422
+ throw new AnalyticsNotInstalledError("POST /analytics/dataset/query", detail || void 0);
3423
+ }
3120
3424
  throw new Error(`Dataset query failed: ${res.status} ${res.statusText}${detail ? ` \u2014 ${detail}` : ""}`);
3121
3425
  }
3122
3426
  const payload = await res.json();
@@ -3130,8 +3434,18 @@ var ObjectStackAdapter = class {
3130
3434
  return { rows, fields, object, dimensionFields, drillRawRows, totals };
3131
3435
  }
3132
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
+ }
3133
3446
  aggregateClientSide(records, params) {
3134
3447
  const { field, function: aggFn, groupBy } = params;
3448
+ const valueKey = this.aggregateValueKey(params);
3135
3449
  const groups = {};
3136
3450
  for (const record of records) {
3137
3451
  const key = String(record[groupBy] ?? "Unknown");
@@ -3139,7 +3453,7 @@ var ObjectStackAdapter = class {
3139
3453
  groups[key].push(record);
3140
3454
  }
3141
3455
  return Object.entries(groups).map(([key, group]) => {
3142
- const values = group.map((r) => Number(r[field]) || 0);
3456
+ const values = field ? group.map((r) => Number(r[field]) || 0) : [];
3143
3457
  let result;
3144
3458
  switch (aggFn) {
3145
3459
  case "count":
@@ -3159,7 +3473,7 @@ var ObjectStackAdapter = class {
3159
3473
  result = values.reduce((a, b) => a + b, 0);
3160
3474
  break;
3161
3475
  }
3162
- return { [groupBy]: key, [field]: result };
3476
+ return { [groupBy]: key, [valueKey]: result };
3163
3477
  });
3164
3478
  }
3165
3479
  /**
@@ -3361,12 +3675,16 @@ function createObjectStackAdapter(config) {
3361
3675
  return new ObjectStackAdapter(config);
3362
3676
  }
3363
3677
  export {
3678
+ AnalyticsNotInstalledError,
3679
+ AnalyticsQueryRejectedError,
3364
3680
  AuthenticationError,
3365
3681
  BulkOperationError,
3366
3682
  CloudOperations,
3367
3683
  ConcurrentUpdateError,
3368
3684
  ConnectionError,
3685
+ FILTER_OPERATOR_ALIASES,
3369
3686
  IntegrationManager,
3687
+ MalformedFilterError,
3370
3688
  MetadataClient,
3371
3689
  MetadataNotFoundError,
3372
3690
  ObjectStackAdapter,
@@ -3374,6 +3692,7 @@ export {
3374
3692
  SecurityManager,
3375
3693
  ValidationError,
3376
3694
  calculateAutoLayout,
3695
+ classifyAnalyticsFailure,
3377
3696
  clearSharedDiscoveryCache,
3378
3697
  createDefaultCanvasConfig,
3379
3698
  createErrorFromResponse,
@@ -3382,11 +3701,14 @@ export {
3382
3701
  generateContractManifest,
3383
3702
  getSharedDiscovery,
3384
3703
  is404Error,
3704
+ isAnalyticsNotInstalledError,
3385
3705
  isConcurrentUpdateError,
3386
3706
  isErrorType,
3707
+ isMalformedFilterError,
3387
3708
  isObjectStackError,
3388
3709
  normaliseClientError,
3389
3710
  readTransactionalBatchCapability,
3711
+ serializeOrderBy,
3390
3712
  snapToGrid,
3391
3713
  validatePluginContract
3392
3714
  };