@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 +103 -0
- package/dist/index.cjs +49 -7
- package/dist/index.js +49 -7
- package/package.json +5 -5
- package/src/index.ts +88 -5
- package/src/onWriteWarning.test.ts +101 -0
- package/src/spec-symbol-batch6.test.ts +4 -1
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
|
|
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 =
|
|
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
|
|
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: [
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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: [
|
|
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(
|
|
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.
|
|
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.
|
|
26
|
-
"@objectstack/spec": "^17.0.0-rc.
|
|
27
|
-
"@object-ui/core": "17.
|
|
28
|
-
"@object-ui/types": "17.
|
|
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
|
|
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: [
|
|
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(
|
|
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,
|