@object-ui/data-objectstack 17.2.0 → 17.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,108 @@
1
1
  # @object-ui/data-objectstack
2
2
 
3
+ ## 17.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d22ae31: Track `@objectstack/spec` 17.0.0-rc.2 (objectui#3235, #3208, #3287, #3264).
8
+
9
+ The pin moves from `^17.0.0-rc.1` to `^17.0.0-rc.2` across the workspace, and
10
+ the sibling `@objectstack/*` packages (`client` / `core` / `formula` / `lint`)
11
+ move with it — they pin `@objectstack/spec` **exactly**, so leaving them behind
12
+ kept a second copy of the spec in the tree and would have had `@objectstack/lint`
13
+ validating against rc.1 schemas that still accept keys rc.2 retires.
14
+
15
+ Breaking semantics, in FROM → TO form:
16
+
17
+ - **`app.homePageId` is retired — an app's landing page is now its first
18
+ navigation item.** An app that pinned a landing page with `homePageId` will
19
+ open on the first reachable navigation entry (by `order`) instead; the root
20
+ landing still follows `isDefault`. To restore a specific landing page, reorder
21
+ `navigation` so the intended entry comes first. Stored metadata is migrated by
22
+ `os migrate meta --from 16`. The key is a hard error now, not a stripped one:
23
+ the spec ships a tombstone that names the migration.
24
+ Upstream retired it because of its SHAPE, not its usage — it was an ID
25
+ cross-reference with no referential integrity, so a `homePageId` that pointed
26
+ at nothing silently fell back to the first navigation item anyway
27
+ (objectstack#4667, premise corrected in #4709). If the capability returns, it
28
+ returns as a flag on the navigation item itself, which cannot dangle.
29
+ - **`@object-ui/types`' `HttpMethod` now resolves to the spec's
30
+ `HttpMethodType`.** Shape is verbatim identical — the same 5-value UI subset —
31
+ and `@object-ui/types` still exports it as `HttpMethod`, so no consumer
32
+ changes. The spec renamed its `./ui` export because `HttpMethod` named two
33
+ different types depending on the import path (`./shared` / `./api` carry a
34
+ 7-value enum including `HEAD` / `OPTIONS`); objectui deliberately keeps the
35
+ 5-value one (objectstack#4691).
36
+ - **`AppContextSelector.includeAll` / `placement` are gone.** Neither ever did
37
+ anything in this renderer: context selectors are mandatory-scope, so no "All"
38
+ row was ever rendered, and `placement: 'topbar'` put nothing in the topbar.
39
+ Both carried schema defaults, which is why the liveness lint structurally
40
+ could not flag them — removal was the only channel that reaches an author
41
+ (framework#4509).
42
+ - **`NavigationArea.visible` / `order` / `requiredPermissions` are gone.** An
43
+ area is a layout grouping, not an access boundary. Gating moved down to the
44
+ navigation ITEM, where `visible` and `requiredPermissions` are unchanged and
45
+ still enforced. `AppSchemaRenderer`'s area switcher no longer hides an area, so
46
+ an area whose items are all gated away renders as visible-but-empty rather
47
+ than disappearing.
48
+ - **`@object-ui/core` no longer exports `NotificationProtocol`**
49
+ (`resolveNotificationConfig`, `specNotificationToToast`, `mapSeverityToVariant`,
50
+ `mapPosition`, `ToastNotification`). It bridged `@objectstack/spec/ui`'s
51
+ `Notification` / `NotificationConfig`, which objectstack#4610 deleted with no
52
+ successor. Use `resolveNotificationConfig` from `@object-ui/react`
53
+ (`NotificationContext`), which owns the live `NotificationSystemConfig` and is
54
+ what every notification surface already read. Note that the spec's _other_
55
+ `Notification` — `@objectstack/spec/api` — is the REST inbox row, a different
56
+ contract, and is deliberately NOT aliased in as a replacement.
57
+ - **The `email_template` client-side validator now uses
58
+ `EmailTemplateDefinitionSchema`.** It was pointing at the removed
59
+ `EmailTemplateSchema`, so authored templates were being checked against the
60
+ wrong contract: the live one is keyed `name` + `locale` (not `id`) and splits
61
+ the body into `bodyHtml` / `bodyText` (not `body` + `bodyType`)
62
+ (objectstack#4616 / #4807).
63
+
64
+ Fixes that are not breaking, but were only found because rc.2 stopped being
65
+ lenient — each had been passing vacuously:
66
+
67
+ - **`view` drafts are actually validated now.** The client validator named the
68
+ aggregated container schema while this admin authors first-class `ViewItem`s,
69
+ and the container used to strip `viewKind` / `config` in silence — so no view
70
+ draft ever had one of its own keys checked. It now validates each shape
71
+ against its own schema (objectui#3312).
72
+ - **The console's worked examples were wrong**, and being stripped rather than
73
+ refused: `view.list.object` (the container root already declares it),
74
+ `job.concurrency` / `job.timeoutMs` (no such keys; the spelling is `timeout`,
75
+ already in ms), `email_template.from` / `.to` (a template is not a send —
76
+ the sender override is `fromOverride`, an object), and
77
+ `datasource.capabilities` / `.healthCheck` (objectstack#4583 removed the
78
+ former; the latter was never a datasource key). These are the drafts an
79
+ author — or a model generating metadata — copies.
80
+ - Action key inventory re-derived: `ActionSchema` gained the package-lock
81
+ envelope (`_lock*` / `_package*` / `_provenance`), so a packaged action no
82
+ longer reports them as unknown keys.
83
+ - The schema-diff panel labels the new `default_mismatch` finding.
84
+ - Test fixtures pinning the retired `managedBy: 'system'` bucket now use
85
+ `engine-owned`. Protocol 17 split that value (objectstack#3355), so it
86
+ resolved to the default-writable fallback and a batch of "stays locked"
87
+ assertions had quietly stopped asserting anything.
88
+
89
+ ### Patch Changes
90
+
91
+ - Updated dependencies [18cd432]
92
+ - Updated dependencies [d915c47]
93
+ - Updated dependencies [5781fb1]
94
+ - Updated dependencies [9e9e9a9]
95
+ - Updated dependencies [23018cc]
96
+ - Updated dependencies [d915c47]
97
+ - Updated dependencies [f44d872]
98
+ - Updated dependencies [509104a]
99
+ - Updated dependencies [a4cff5b]
100
+ - Updated dependencies [f833d3a]
101
+ - Updated dependencies [2a9513d]
102
+ - Updated dependencies [d22ae31]
103
+ - @object-ui/core@17.3.0
104
+ - @object-ui/types@17.3.0
105
+
3
106
  ## 17.2.0
4
107
 
5
108
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -76,6 +76,7 @@ function errorCodeIsAnyOf(err, codes) {
76
76
  }
77
77
 
78
78
  // ../core/src/utils/filter-converter.ts
79
+ var import_ui = require("@objectstack/spec/ui");
79
80
  var FilterOperatorError = class extends Error {
80
81
  constructor(message) {
81
82
  super(message);
@@ -1504,7 +1505,7 @@ var FILTER_OPERATOR_ALIASES = {
1504
1505
  before: "<",
1505
1506
  after: ">"
1506
1507
  };
1507
- function normalizeFilterOperator(op) {
1508
+ function normalizeFilterOperator2(op) {
1508
1509
  if (typeof op !== "string") return null;
1509
1510
  const lower = op.toLowerCase();
1510
1511
  return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
@@ -1533,7 +1534,7 @@ function objectFilterEntryToAST(entry) {
1533
1534
  if (!entry || typeof entry !== "object") return null;
1534
1535
  const field = entry.field;
1535
1536
  const rawOp = entry.operator ?? entry.op ?? "=";
1536
- const op = normalizeFilterOperator(rawOp);
1537
+ const op = normalizeFilterOperator2(rawOp);
1537
1538
  if (!field || !op) return null;
1538
1539
  return [String(field), op, entry.value];
1539
1540
  }
@@ -1751,6 +1752,31 @@ function stableStringify(value) {
1751
1752
  const keys = Object.keys(obj).sort();
1752
1753
  return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
1753
1754
  }
1755
+ function sameWireValue(a, b) {
1756
+ if (a === b) return true;
1757
+ const aEmpty = a === null || a === void 0;
1758
+ const bEmpty = b === null || b === void 0;
1759
+ if (aEmpty || bEmpty) return aEmpty && bEmpty;
1760
+ return stableStringify(a) === stableStringify(b);
1761
+ }
1762
+ function withoutNoOpDrops(droppedFields, sent, stored) {
1763
+ if (!sent || !stored || typeof sent !== "object" || typeof stored !== "object") {
1764
+ return droppedFields;
1765
+ }
1766
+ const out = [];
1767
+ for (const e of droppedFields) {
1768
+ const kept = e.fields.filter((f) => {
1769
+ if (!Object.prototype.hasOwnProperty.call(sent, f)) return true;
1770
+ if (!Object.prototype.hasOwnProperty.call(stored, f)) return true;
1771
+ return !sameWireValue(
1772
+ sent[f],
1773
+ stored[f]
1774
+ );
1775
+ });
1776
+ if (kept.length > 0) out.push({ ...e, fields: kept });
1777
+ }
1778
+ return out;
1779
+ }
1754
1780
  var ObjectStackAdapter = class {
1755
1781
  constructor(config) {
1756
1782
  __publicField(this, "client");
@@ -2127,12 +2153,14 @@ var ObjectStackAdapter = class {
2127
2153
  * whose response type predates `droppedFields`: the field is read structurally
2128
2154
  * and validated, so an older client (or a backend that never drops) is a no-op.
2129
2155
  */
2130
- notifyDroppedFields(operation, resource, result, id) {
2156
+ notifyDroppedFields(operation, resource, result, id, sent) {
2131
2157
  const dropped = result?.droppedFields;
2132
2158
  if (!Array.isArray(dropped) || dropped.length === 0) return;
2133
- const droppedFields = dropped.filter(
2159
+ const valid = dropped.filter(
2134
2160
  (e) => !!e && typeof e === "object" && Array.isArray(e.fields) && e.fields.length > 0
2135
2161
  );
2162
+ const stored = result?.record;
2163
+ const droppedFields = withoutNoOpDrops(valid, sent, stored);
2136
2164
  if (droppedFields.length === 0) return;
2137
2165
  this.emitWriteWarning({ operation, resource, ...id !== void 0 ? { id } : {}, droppedFields });
2138
2166
  }
@@ -2151,17 +2179,25 @@ var ObjectStackAdapter = class {
2151
2179
  notifyBatchDroppedFields(operations, payload) {
2152
2180
  const dropped = payload?.droppedFields;
2153
2181
  if (!Array.isArray(dropped) || dropped.length === 0) return;
2182
+ const results = payload?.results;
2154
2183
  for (const entry of dropped) {
2155
2184
  if (!entry || typeof entry !== "object") continue;
2156
2185
  const e = entry;
2157
2186
  if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
2158
2187
  const op = typeof e.index === "number" ? operations[e.index] : void 0;
2188
+ const stored = typeof e.index === "number" && Array.isArray(results) ? results[e.index] : void 0;
2189
+ const [live] = withoutNoOpDrops(
2190
+ [{ object: e.object, fields: e.fields, reason: e.reason }],
2191
+ op?.data,
2192
+ stored
2193
+ );
2194
+ if (!live) continue;
2159
2195
  const operation = (op?.action ?? "create") === "create" ? "create" : "update";
2160
2196
  this.emitWriteWarning({
2161
2197
  operation,
2162
2198
  resource: e.object ?? op?.object ?? "",
2163
2199
  ...op?.id !== void 0 && op?.id !== null ? { id: op.id } : {},
2164
- droppedFields: [{ object: e.object, fields: e.fields, reason: e.reason }]
2200
+ droppedFields: [live]
2165
2201
  });
2166
2202
  }
2167
2203
  }
@@ -2181,7 +2217,13 @@ var ObjectStackAdapter = class {
2181
2217
  try {
2182
2218
  const result = await this.client.data.create(resource, data);
2183
2219
  this.emitMutation({ type: "create", resource, record: { ...result.record } });
2184
- this.notifyDroppedFields("create", resource, result, result.record?.id);
2220
+ this.notifyDroppedFields(
2221
+ "create",
2222
+ resource,
2223
+ result,
2224
+ result.record?.id,
2225
+ data
2226
+ );
2185
2227
  return result.record;
2186
2228
  } catch (err) {
2187
2229
  throw normaliseClientError(err);
@@ -2208,7 +2250,7 @@ var ObjectStackAdapter = class {
2208
2250
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
2209
2251
  );
2210
2252
  this.emitMutation({ type: "update", resource, id, record: { ...result.record } });
2211
- this.notifyDroppedFields("update", resource, result, id);
2253
+ this.notifyDroppedFields("update", resource, result, id, data);
2212
2254
  return result.record;
2213
2255
  } catch (err) {
2214
2256
  throw normaliseClientError(err);
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ function errorCodeIsAnyOf(err, codes) {
23
23
  }
24
24
 
25
25
  // ../core/src/utils/filter-converter.ts
26
+ import { normalizeFilterOperator } from "@objectstack/spec/ui";
26
27
  var FilterOperatorError = class extends Error {
27
28
  constructor(message) {
28
29
  super(message);
@@ -1451,7 +1452,7 @@ var FILTER_OPERATOR_ALIASES = {
1451
1452
  before: "<",
1452
1453
  after: ">"
1453
1454
  };
1454
- function normalizeFilterOperator(op) {
1455
+ function normalizeFilterOperator2(op) {
1455
1456
  if (typeof op !== "string") return null;
1456
1457
  const lower = op.toLowerCase();
1457
1458
  return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
@@ -1480,7 +1481,7 @@ function objectFilterEntryToAST(entry) {
1480
1481
  if (!entry || typeof entry !== "object") return null;
1481
1482
  const field = entry.field;
1482
1483
  const rawOp = entry.operator ?? entry.op ?? "=";
1483
- const op = normalizeFilterOperator(rawOp);
1484
+ const op = normalizeFilterOperator2(rawOp);
1484
1485
  if (!field || !op) return null;
1485
1486
  return [String(field), op, entry.value];
1486
1487
  }
@@ -1698,6 +1699,31 @@ function stableStringify(value) {
1698
1699
  const keys = Object.keys(obj).sort();
1699
1700
  return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
1700
1701
  }
1702
+ function sameWireValue(a, b) {
1703
+ if (a === b) return true;
1704
+ const aEmpty = a === null || a === void 0;
1705
+ const bEmpty = b === null || b === void 0;
1706
+ if (aEmpty || bEmpty) return aEmpty && bEmpty;
1707
+ return stableStringify(a) === stableStringify(b);
1708
+ }
1709
+ function withoutNoOpDrops(droppedFields, sent, stored) {
1710
+ if (!sent || !stored || typeof sent !== "object" || typeof stored !== "object") {
1711
+ return droppedFields;
1712
+ }
1713
+ const out = [];
1714
+ for (const e of droppedFields) {
1715
+ const kept = e.fields.filter((f) => {
1716
+ if (!Object.prototype.hasOwnProperty.call(sent, f)) return true;
1717
+ if (!Object.prototype.hasOwnProperty.call(stored, f)) return true;
1718
+ return !sameWireValue(
1719
+ sent[f],
1720
+ stored[f]
1721
+ );
1722
+ });
1723
+ if (kept.length > 0) out.push({ ...e, fields: kept });
1724
+ }
1725
+ return out;
1726
+ }
1701
1727
  var ObjectStackAdapter = class {
1702
1728
  constructor(config) {
1703
1729
  __publicField(this, "client");
@@ -2074,12 +2100,14 @@ var ObjectStackAdapter = class {
2074
2100
  * whose response type predates `droppedFields`: the field is read structurally
2075
2101
  * and validated, so an older client (or a backend that never drops) is a no-op.
2076
2102
  */
2077
- notifyDroppedFields(operation, resource, result, id) {
2103
+ notifyDroppedFields(operation, resource, result, id, sent) {
2078
2104
  const dropped = result?.droppedFields;
2079
2105
  if (!Array.isArray(dropped) || dropped.length === 0) return;
2080
- const droppedFields = dropped.filter(
2106
+ const valid = dropped.filter(
2081
2107
  (e) => !!e && typeof e === "object" && Array.isArray(e.fields) && e.fields.length > 0
2082
2108
  );
2109
+ const stored = result?.record;
2110
+ const droppedFields = withoutNoOpDrops(valid, sent, stored);
2083
2111
  if (droppedFields.length === 0) return;
2084
2112
  this.emitWriteWarning({ operation, resource, ...id !== void 0 ? { id } : {}, droppedFields });
2085
2113
  }
@@ -2098,17 +2126,25 @@ var ObjectStackAdapter = class {
2098
2126
  notifyBatchDroppedFields(operations, payload) {
2099
2127
  const dropped = payload?.droppedFields;
2100
2128
  if (!Array.isArray(dropped) || dropped.length === 0) return;
2129
+ const results = payload?.results;
2101
2130
  for (const entry of dropped) {
2102
2131
  if (!entry || typeof entry !== "object") continue;
2103
2132
  const e = entry;
2104
2133
  if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
2105
2134
  const op = typeof e.index === "number" ? operations[e.index] : void 0;
2135
+ const stored = typeof e.index === "number" && Array.isArray(results) ? results[e.index] : void 0;
2136
+ const [live] = withoutNoOpDrops(
2137
+ [{ object: e.object, fields: e.fields, reason: e.reason }],
2138
+ op?.data,
2139
+ stored
2140
+ );
2141
+ if (!live) continue;
2106
2142
  const operation = (op?.action ?? "create") === "create" ? "create" : "update";
2107
2143
  this.emitWriteWarning({
2108
2144
  operation,
2109
2145
  resource: e.object ?? op?.object ?? "",
2110
2146
  ...op?.id !== void 0 && op?.id !== null ? { id: op.id } : {},
2111
- droppedFields: [{ object: e.object, fields: e.fields, reason: e.reason }]
2147
+ droppedFields: [live]
2112
2148
  });
2113
2149
  }
2114
2150
  }
@@ -2128,7 +2164,13 @@ var ObjectStackAdapter = class {
2128
2164
  try {
2129
2165
  const result = await this.client.data.create(resource, data);
2130
2166
  this.emitMutation({ type: "create", resource, record: { ...result.record } });
2131
- this.notifyDroppedFields("create", resource, result, result.record?.id);
2167
+ this.notifyDroppedFields(
2168
+ "create",
2169
+ resource,
2170
+ result,
2171
+ result.record?.id,
2172
+ data
2173
+ );
2132
2174
  return result.record;
2133
2175
  } catch (err) {
2134
2176
  throw normaliseClientError(err);
@@ -2155,7 +2197,7 @@ var ObjectStackAdapter = class {
2155
2197
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
2156
2198
  );
2157
2199
  this.emitMutation({ type: "update", resource, id, record: { ...result.record } });
2158
- this.notifyDroppedFields("update", resource, result, id);
2200
+ this.notifyDroppedFields("update", resource, result, id, data);
2159
2201
  return result.record;
2160
2202
  } catch (err) {
2161
2203
  throw normaliseClientError(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/data-objectstack",
3
- "version": "17.2.0",
3
+ "version": "17.3.0",
4
4
  "description": "ObjectStack Data Adapter for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,10 +22,10 @@
22
22
  "LICENSE"
23
23
  ],
24
24
  "dependencies": {
25
- "@objectstack/client": "^17.0.0-rc.1",
26
- "@objectstack/spec": "^17.0.0-rc.1",
27
- "@object-ui/core": "17.2.0",
28
- "@object-ui/types": "17.2.0"
25
+ "@objectstack/client": "^17.0.0-rc.2",
26
+ "@objectstack/spec": "^17.0.0-rc.2",
27
+ "@object-ui/core": "17.3.0",
28
+ "@object-ui/types": "17.3.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "tsup": "^8.5.1",
package/src/index.ts CHANGED
@@ -771,9 +771,68 @@ function stableStringify(value: unknown): string {
771
771
  return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`;
772
772
  }
773
773
 
774
+ /**
775
+ * Whether two values are the SAME as far as the wire is concerned — used to
776
+ * tell a write-strip that lost something from one that lost nothing
777
+ * (objectui#3484).
778
+ *
779
+ * Deliberately strict: `1` and `'1'` are NOT the same here. A false "these are
780
+ * equal" silently swallows a warning about a value the user really did lose,
781
+ * which is the worse of the two errors; a false "these differ" only warns
782
+ * about a no-op. `null` and `undefined` both mean "empty" and do match.
783
+ */
784
+ function sameWireValue(a: unknown, b: unknown): boolean {
785
+ if (a === b) return true;
786
+ const aEmpty = a === null || a === undefined;
787
+ const bEmpty = b === null || b === undefined;
788
+ if (aEmpty || bEmpty) return aEmpty && bEmpty;
789
+ return stableStringify(a) === stableStringify(b);
790
+ }
791
+
792
+ /**
793
+ * Drop the fields whose strip changed nothing — the caller supplied exactly
794
+ * the value the record already holds (objectui#3484).
795
+ *
796
+ * The server is not wrong to report those: the client DID send the key and the
797
+ * engine DID refuse to write it, so `droppedFields` is an accurate account of
798
+ * the request. But "was not saved" is only news when something was actually
799
+ * lost, and the console's edit form used to round-trip the whole record —
800
+ * including fields it had itself rendered as read-only text — so every save of
801
+ * a state-locked record raised a warning listing fields the user never touched
802
+ * and whose values never changed.
803
+ *
804
+ * A field is kept (still warned about) whenever the no-op cannot be PROVEN:
805
+ * the response echoed no record, or that record does not carry the key.
806
+ *
807
+ * Returns entries with empty `fields` removed; an all-no-op event list comes
808
+ * back empty, which suppresses the warning entirely.
809
+ */
810
+ function withoutNoOpDrops(
811
+ droppedFields: DroppedFieldsEvent[],
812
+ sent: Record<string, unknown> | undefined | null,
813
+ stored: Record<string, unknown> | undefined | null,
814
+ ): DroppedFieldsEvent[] {
815
+ if (!sent || !stored || typeof sent !== 'object' || typeof stored !== 'object') {
816
+ return droppedFields;
817
+ }
818
+ const out: DroppedFieldsEvent[] = [];
819
+ for (const e of droppedFields) {
820
+ const kept = e.fields.filter((f) => {
821
+ if (!Object.prototype.hasOwnProperty.call(sent, f)) return true;
822
+ if (!Object.prototype.hasOwnProperty.call(stored, f)) return true;
823
+ return !sameWireValue(
824
+ (sent as Record<string, unknown>)[f],
825
+ (stored as Record<string, unknown>)[f],
826
+ );
827
+ });
828
+ if (kept.length > 0) out.push({ ...e, fields: kept });
829
+ }
830
+ return out;
831
+ }
832
+
774
833
  /**
775
834
  * ObjectStack Data Source Adapter
776
- *
835
+ *
777
836
  * Bridges the ObjectStack Client SDK with the ObjectUI DataSource interface.
778
837
  * This allows Object UI applications to seamlessly integrate with ObjectStack
779
838
  * backends while maintaining the universal DataSource abstraction.
@@ -1309,13 +1368,17 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1309
1368
  resource: string,
1310
1369
  result: unknown,
1311
1370
  id?: string | number,
1371
+ sent?: Record<string, unknown> | null,
1312
1372
  ): void {
1313
1373
  const dropped = (result as { droppedFields?: unknown } | null | undefined)?.droppedFields;
1314
1374
  if (!Array.isArray(dropped) || dropped.length === 0) return;
1315
- const droppedFields = dropped.filter(
1375
+ const valid = dropped.filter(
1316
1376
  (e): e is DroppedFieldsEvent =>
1317
1377
  !!e && typeof e === 'object' && Array.isArray((e as DroppedFieldsEvent).fields) && (e as DroppedFieldsEvent).fields.length > 0,
1318
1378
  );
1379
+ // A strip that changed nothing is not news — see withoutNoOpDrops (#3484).
1380
+ const stored = (result as { record?: Record<string, unknown> } | null | undefined)?.record;
1381
+ const droppedFields = withoutNoOpDrops(valid, sent, stored);
1319
1382
  if (droppedFields.length === 0) return;
1320
1383
  this.emitWriteWarning({ operation, resource, ...(id !== undefined ? { id } : {}), droppedFields });
1321
1384
  }
@@ -1338,11 +1401,25 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1338
1401
  ): void {
1339
1402
  const dropped = (payload as { droppedFields?: unknown } | null | undefined)?.droppedFields;
1340
1403
  if (!Array.isArray(dropped) || dropped.length === 0) return;
1404
+ const results = (payload as { results?: unknown[] } | null | undefined)?.results;
1341
1405
  for (const entry of dropped) {
1342
1406
  if (!entry || typeof entry !== 'object') continue;
1343
1407
  const e = entry as DroppedFieldsEvent & { index?: number };
1344
1408
  if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
1345
1409
  const op = typeof e.index === 'number' ? operations[e.index] : undefined;
1410
+ // Same no-op suppression as the single-record path (#3484). The echoed
1411
+ // row for the originating op is the "stored" side; when the batch echoed
1412
+ // nothing usable, `withoutNoOpDrops` keeps every field.
1413
+ const stored =
1414
+ typeof e.index === 'number' && Array.isArray(results)
1415
+ ? (results[e.index] as Record<string, unknown> | undefined)
1416
+ : undefined;
1417
+ const [live] = withoutNoOpDrops(
1418
+ [{ object: e.object, fields: e.fields, reason: e.reason }],
1419
+ op?.data as Record<string, unknown> | undefined,
1420
+ stored,
1421
+ );
1422
+ if (!live) continue;
1346
1423
  // `delete` never drops fields; anything unexpected reads as an update,
1347
1424
  // which is the truthful default for a batch that echoed a strip.
1348
1425
  const operation: 'create' | 'update' = (op?.action ?? 'create') === 'create' ? 'create' : 'update';
@@ -1350,7 +1427,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1350
1427
  operation,
1351
1428
  resource: e.object ?? op?.object ?? '',
1352
1429
  ...(op?.id !== undefined && op?.id !== null ? { id: op.id } : {}),
1353
- droppedFields: [{ object: e.object, fields: e.fields, reason: e.reason }],
1430
+ droppedFields: [live],
1354
1431
  });
1355
1432
  }
1356
1433
  }
@@ -1372,7 +1449,13 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1372
1449
  try {
1373
1450
  const result = await this.client.data.create<T>(resource, data);
1374
1451
  this.emitMutation({ type: 'create', resource, record: { ...result.record } });
1375
- this.notifyDroppedFields('create', resource, result, (result.record as { id?: string | number } | undefined)?.id);
1452
+ this.notifyDroppedFields(
1453
+ 'create',
1454
+ resource,
1455
+ result,
1456
+ (result.record as { id?: string | number } | undefined)?.id,
1457
+ data as Record<string, unknown>,
1458
+ );
1376
1459
  return result.record;
1377
1460
  } catch (err) {
1378
1461
  // `update` has always normalised; `create` did not, so a rejected insert
@@ -1409,7 +1492,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1409
1492
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : undefined,
1410
1493
  );
1411
1494
  this.emitMutation({ type: 'update', resource, id, record: { ...result.record } });
1412
- this.notifyDroppedFields('update', resource, result, id);
1495
+ this.notifyDroppedFields('update', resource, result, id, data as Record<string, unknown>);
1413
1496
  return result.record;
1414
1497
  } catch (err) {
1415
1498
  throw normaliseClientError(err);
@@ -157,6 +157,107 @@ describe('ObjectStackAdapter.onWriteWarning', () => {
157
157
  expect(events).toHaveLength(1);
158
158
  });
159
159
 
160
+ // ── no-op strips are not news (objectui#3484) ─────────────────────────────
161
+ //
162
+ // The server is right to report a strip — the client DID send the key. But
163
+ // "was not saved" is only worth a toast when something was LOST. The console
164
+ // edit form used to round-trip the whole record, so every save of a
165
+ // state-locked record warned about fields the user never touched whose values
166
+ // never changed. When the echoed record proves the stripped value equals what
167
+ // was sent, stay quiet.
168
+
169
+ it('suppresses the warning when every stripped value equals what the record already holds', async () => {
170
+ const update = vi.fn().mockResolvedValue({
171
+ record: { id: 'r1', type: 'quality_abnormal', source_method: 'qc_judgment', title: 'B' },
172
+ droppedFields: [
173
+ { object: 'andon', fields: ['type', 'source_method'], reason: 'readonly_when' },
174
+ ],
175
+ });
176
+ const ds = makeDS({ update });
177
+ const events: WriteWarningEvent[] = [];
178
+ ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
179
+
180
+ await ds.update('andon', 'r1', {
181
+ title: 'B',
182
+ type: 'quality_abnormal',
183
+ source_method: 'qc_judgment',
184
+ });
185
+
186
+ expect(events).toEqual([]);
187
+ });
188
+
189
+ it('still warns about the fields that really did change, and only those', async () => {
190
+ const update = vi.fn().mockResolvedValue({
191
+ record: { id: 'r1', type: 'quality_abnormal', source_method: 'qc_judgment' },
192
+ droppedFields: [
193
+ { object: 'andon', fields: ['type', 'source_method'], reason: 'readonly_when' },
194
+ ],
195
+ });
196
+ const ds = makeDS({ update });
197
+ const events: WriteWarningEvent[] = [];
198
+ ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
199
+
200
+ // The user really did try to change `type`; `source_method` round-tripped.
201
+ await ds.update('andon', 'r1', { type: 'equipment_fault', source_method: 'qc_judgment' });
202
+
203
+ expect(events).toEqual([
204
+ {
205
+ operation: 'update',
206
+ resource: 'andon',
207
+ id: 'r1',
208
+ droppedFields: [{ object: 'andon', fields: ['type'], reason: 'readonly_when' }],
209
+ },
210
+ ]);
211
+ });
212
+
213
+ it('keeps the warning when the no-op cannot be PROVEN (record echo lacks the key)', async () => {
214
+ const update = vi.fn().mockResolvedValue({
215
+ record: { id: 'r1' },
216
+ droppedFields: [{ object: 'andon', fields: ['type'], reason: 'readonly_when' }],
217
+ });
218
+ const ds = makeDS({ update });
219
+ const events: WriteWarningEvent[] = [];
220
+ ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
221
+
222
+ await ds.update('andon', 'r1', { type: 'quality_abnormal' });
223
+
224
+ expect(events).toHaveLength(1);
225
+ });
226
+
227
+ it('does not treat a type change as a no-op (1 is not "1")', async () => {
228
+ const update = vi.fn().mockResolvedValue({
229
+ record: { id: 'r1', tax_rate: '9' },
230
+ droppedFields: [{ object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when' }],
231
+ });
232
+ const ds = makeDS({ update });
233
+ const events: WriteWarningEvent[] = [];
234
+ ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
235
+
236
+ await ds.update('invoice', 'r1', { tax_rate: 9 });
237
+
238
+ expect(events).toHaveLength(1);
239
+ });
240
+
241
+ it('suppresses a no-op strip on the cross-object batch path too', async () => {
242
+ const batchTransaction = vi.fn().mockResolvedValue({
243
+ results: [{ id: 'acc1' }, { id: 'inv1', tax_rate: 9 }],
244
+ droppedFields: [
245
+ { object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when', index: 1 },
246
+ ],
247
+ });
248
+ const ds = makeDS({ batchTransaction });
249
+ ds.atomicBatchCapability = true;
250
+ const events: WriteWarningEvent[] = [];
251
+ ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
252
+
253
+ await ds.batchTransaction([
254
+ { object: 'account', action: 'create', data: { name: 'Acme' } },
255
+ { object: 'invoice', action: 'update', id: 'inv1', data: { status: 'paid', tax_rate: 9 } },
256
+ ]);
257
+
258
+ expect(events).toEqual([]);
259
+ });
260
+
160
261
  it('isolates a throwing listener so the write still resolves and other listeners fire', async () => {
161
262
  const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, droppedFields: ONE_DROP });
162
263
  const ds = makeDS({ update });
@@ -57,10 +57,13 @@ import type { DroppedFieldsEvent } from './index';
57
57
  import type { DroppedFieldsEvent as SpecDroppedFieldsEvent } from '@objectstack/spec/data';
58
58
  import type { CacheStats as SpecCacheStats } from '@objectstack/spec/contracts';
59
59
  import type {
60
- MetadataSaveOptions as SpecMetadataSaveOptions,
61
60
  SecurityPolicy as SpecSecurityPolicy,
62
61
  ValidationError as SpecValidationError,
63
62
  } from '@objectstack/spec/kernel';
63
+ // `MetadataSaveOptions` moved `./kernel` → `./system` in spec 17.0.0-rc.2.
64
+ // The name still belongs to the spec and still means the FILE writer, so the
65
+ // rename this batch recorded stands — only the entry point moved.
66
+ import type { MetadataSaveOptions as SpecMetadataSaveOptions } from '@objectstack/spec/system';
64
67
 
65
68
  const PROBE_OPTIONS: ts.CompilerOptions = {
66
69
  noEmit: true,