@atscript/ui 0.1.101 → 0.1.103
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +430 -0
- package/dist/index.d.cts +68 -1
- package/dist/index.d.mts +68 -1
- package/dist/index.mjs +430 -1
- package/package.json +8 -8
package/dist/index.cjs
CHANGED
|
@@ -948,6 +948,435 @@ function createFieldValidator(prop, opts) {
|
|
|
948
948
|
};
|
|
949
949
|
}
|
|
950
950
|
//#endregion
|
|
951
|
+
//#region src/form/diff.ts
|
|
952
|
+
/**
|
|
953
|
+
* Diffs a form's `current` data against its `baseline` snapshot, producing both
|
|
954
|
+
* a changed-fields list and an `@atscript/db` patch object.
|
|
955
|
+
*
|
|
956
|
+
* Both `baseline` and `current` are the WRAPPED form-data container
|
|
957
|
+
* (`{ value: domainData }`) so this reuses {@link getByPath}.
|
|
958
|
+
*
|
|
959
|
+
* Revert-aware: a value edited back to its baseline produces no change and no
|
|
960
|
+
* patch entry.
|
|
961
|
+
*
|
|
962
|
+
* Snapshot contract: the result is NOT a deep copy. `$insert` items, `$replace`
|
|
963
|
+
* arrays, scalar leaf values, and `changes[].before/after` all hold live
|
|
964
|
+
* references into `baseline` / `current`. Callers that keep editing the form
|
|
965
|
+
* after building the patch must snapshot first (e.g. build the patch at submit
|
|
966
|
+
* time on a frozen clone). This is the common Vue v-model flow.
|
|
967
|
+
*/
|
|
968
|
+
function buildFormDiff(def, baseline, current, opts) {
|
|
969
|
+
const changes = [];
|
|
970
|
+
const patch = {};
|
|
971
|
+
const versionColumn = findVersionColumn(def);
|
|
972
|
+
diffFields(def.fields, "", baseline, current, changes, patch, versionColumn, def.flatMap);
|
|
973
|
+
if ((opts?.cas ?? true) && versionColumn && Object.keys(patch).length > 0) {
|
|
974
|
+
const baselineVersion = getByPath(baseline, versionColumn);
|
|
975
|
+
if (typeof baselineVersion === "number" && Number.isInteger(baselineVersion)) patch.$cas = { [versionColumn]: baselineVersion };
|
|
976
|
+
}
|
|
977
|
+
return {
|
|
978
|
+
isDirty: changes.length > 0,
|
|
979
|
+
changes,
|
|
980
|
+
patch
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Diffs a list of sibling fields. `prefix` is the dot-path of the parent
|
|
985
|
+
* context relative to the form root (used only for the change list `path`);
|
|
986
|
+
* patch entries are written into the local `patch` object so callers can place
|
|
987
|
+
* the whole sub-object as a nested partial.
|
|
988
|
+
*
|
|
989
|
+
* `versionColumn` is the top-level `@db.column.version` field name (or
|
|
990
|
+
* undefined). When set, the matching top-level field is skipped entirely — it
|
|
991
|
+
* is server-managed and may only be round-tripped via `$cas`.
|
|
992
|
+
*
|
|
993
|
+
* `inlineFlatMap` is the form's `flatMap`, supplied only at the top-level walk.
|
|
994
|
+
* It lets {@link diffScalarField} read `@db.patch.strategy` off the object
|
|
995
|
+
* ancestors of an INLINED leaf (a dotted-path leaf with no `FormObjectFieldDef`
|
|
996
|
+
* node — `createFormDef` dissolves unlabelled objects into dot-paths). At a
|
|
997
|
+
* default (replace) ancestor the whole sub-object must be emitted. It is
|
|
998
|
+
* intentionally NOT propagated into `diffObjectField` recursion, where the
|
|
999
|
+
* strategy decision is already made per structured object.
|
|
1000
|
+
*/
|
|
1001
|
+
function diffFields(fields, prefix, baseline, current, changes, patch, versionColumn, inlineFlatMap) {
|
|
1002
|
+
for (const field of fields) {
|
|
1003
|
+
if (field.path === "" && fields.length === 1) {
|
|
1004
|
+
diffLeafRoot(field, baseline, current, changes, patch);
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
if (field.phantom) continue;
|
|
1008
|
+
if (versionColumn !== void 0 && !prefix && field.path === versionColumn) continue;
|
|
1009
|
+
const fullPath = prefix ? `${prefix}.${field.path}` : field.path;
|
|
1010
|
+
if (isArrayField(field)) {
|
|
1011
|
+
diffArrayField(field, fullPath, baseline, current, changes, patch);
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
if (isObjectField(field)) {
|
|
1015
|
+
diffObjectField(field, fullPath, baseline, current, changes, patch);
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
diffScalarField(field, fullPath, baseline, current, changes, patch, inlineFlatMap);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
/** Single-leaf root form (non-object root). Whole value is the patch. */
|
|
1022
|
+
function diffLeafRoot(field, baseline, current, changes, patch) {
|
|
1023
|
+
const before = getByPath(baseline, "");
|
|
1024
|
+
const after = getByPath(current, "");
|
|
1025
|
+
if (deepEqual(before, after)) return;
|
|
1026
|
+
if (isArrayField(field)) {
|
|
1027
|
+
const arrayPatch = diffArray(field, before, after);
|
|
1028
|
+
if (arrayPatch === void 0) return;
|
|
1029
|
+
changes.push({
|
|
1030
|
+
path: "",
|
|
1031
|
+
kind: "array",
|
|
1032
|
+
before,
|
|
1033
|
+
after
|
|
1034
|
+
});
|
|
1035
|
+
patch.value = arrayPatch;
|
|
1036
|
+
} else {
|
|
1037
|
+
changes.push({
|
|
1038
|
+
path: "",
|
|
1039
|
+
kind: "set",
|
|
1040
|
+
before,
|
|
1041
|
+
after
|
|
1042
|
+
});
|
|
1043
|
+
patch.value = after === void 0 ? null : after;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
/** Scalar / union / tuple / ref field — whole-value compare; clear → null. */
|
|
1047
|
+
function diffScalarField(field, fullPath, baseline, current, changes, patch, inlineFlatMap) {
|
|
1048
|
+
const before = getByPath(baseline, fullPath);
|
|
1049
|
+
const after = getByPath(current, fullPath);
|
|
1050
|
+
if (deepEqual(before, after)) return;
|
|
1051
|
+
changes.push({
|
|
1052
|
+
path: fullPath,
|
|
1053
|
+
kind: "set",
|
|
1054
|
+
before,
|
|
1055
|
+
after
|
|
1056
|
+
});
|
|
1057
|
+
if (inlineFlatMap && field.path.includes(".")) {
|
|
1058
|
+
const cutoff = inlinedReplaceCutoff(field.path, inlineFlatMap);
|
|
1059
|
+
if (cutoff !== void 0) {
|
|
1060
|
+
const prefixLen = fullPath.length - field.path.length;
|
|
1061
|
+
const sub = getByPath(current, prefixLen > 0 ? fullPath.slice(0, prefixLen) + cutoff : cutoff);
|
|
1062
|
+
setPatchLeaf(patch, cutoff, sub === void 0 ? null : sub);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
setPatchLeaf(patch, field.path, after === void 0 ? null : after);
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* For a dotted INLINED leaf path, returns the path of the shallowest object
|
|
1070
|
+
* ancestor whose `@db.patch.strategy` is the default (`replace`), or undefined
|
|
1071
|
+
* when every ancestor is `merge` (then the leaf partial is correct). At a
|
|
1072
|
+
* replace ancestor the whole sub-object must be present in the patch.
|
|
1073
|
+
*
|
|
1074
|
+
* Walks ancestor segments (`a`, `a.b`, … but not the leaf itself); the first
|
|
1075
|
+
* one that is an object AND not merge is the cutoff. merge does NOT propagate,
|
|
1076
|
+
* so a default-replace level below a merge level still cuts off there.
|
|
1077
|
+
*/
|
|
1078
|
+
function inlinedReplaceCutoff(leafPath, flatMap) {
|
|
1079
|
+
const segs = leafPath.split(".");
|
|
1080
|
+
let acc = "";
|
|
1081
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
1082
|
+
acc = acc ? `${acc}.${segs[i]}` : segs[i];
|
|
1083
|
+
const prop = flatMap.get(acc);
|
|
1084
|
+
if (!prop || prop.type.kind !== "object") continue;
|
|
1085
|
+
if (getFieldMeta(prop, "db.patch.strategy") !== "merge") return acc;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Inlined-or-structured object field — recurse, emit a nested partial OR the
|
|
1090
|
+
* whole sub-object depending on the field's `@db.patch.strategy`.
|
|
1091
|
+
*
|
|
1092
|
+
* atscript-db's DEFAULT nested-object patch strategy is `replace` (strict —
|
|
1093
|
+
* every required child must be present, else 400; omitted optionals are
|
|
1094
|
+
* null-filled). A changed-leaves-only partial is a valid patch ONLY when the
|
|
1095
|
+
* object field carries `@db.patch.strategy 'merge'`. For the default (replace)
|
|
1096
|
+
* case we therefore emit the WHOLE current sub-object so the validator passes
|
|
1097
|
+
* and no optional leaf is silently nulled. `merge` does NOT propagate, so a
|
|
1098
|
+
* descendant object without its own `merge` again emits its full sub-object
|
|
1099
|
+
* (handled by recursion — `diffFields` re-enters this function per child).
|
|
1100
|
+
*
|
|
1101
|
+
* Wholesale-clear: if the sub-object was a defined object in `baseline` but is
|
|
1102
|
+
* now undefined/null, emit `field: null` (object removed) instead of a partial
|
|
1103
|
+
* of nulled leaves.
|
|
1104
|
+
*/
|
|
1105
|
+
function diffObjectField(field, fullPath, baseline, current, changes, patch) {
|
|
1106
|
+
const beforeObj = getByPath(baseline, fullPath);
|
|
1107
|
+
const afterObj = getByPath(current, fullPath);
|
|
1108
|
+
const nested = {};
|
|
1109
|
+
diffFields(field.objectDef.fields, fullPath, baseline, current, changes, nested, void 0, field.objectDef.flatMap);
|
|
1110
|
+
if ((afterObj === void 0 || afterObj === null) && isPlainObject(beforeObj)) {
|
|
1111
|
+
setPatchLeaf(patch, field.path, null);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
if (Object.keys(nested).length === 0) return;
|
|
1115
|
+
if (getFieldMeta(field.prop, "db.patch.strategy") === "merge") setPatchLeaf(patch, field.path, nested);
|
|
1116
|
+
else setPatchLeaf(patch, field.path, afterObj === void 0 ? null : afterObj);
|
|
1117
|
+
}
|
|
1118
|
+
/** Array field — keyed → $update/$insert/$remove; unkeyed → $replace. */
|
|
1119
|
+
function diffArrayField(field, fullPath, baseline, current, changes, patch) {
|
|
1120
|
+
const before = getByPath(baseline, fullPath);
|
|
1121
|
+
const after = getByPath(current, fullPath);
|
|
1122
|
+
if (deepEqual(before, after)) return;
|
|
1123
|
+
const arrayPatch = diffArray(field, before, after);
|
|
1124
|
+
if (arrayPatch === void 0) return;
|
|
1125
|
+
changes.push({
|
|
1126
|
+
path: fullPath,
|
|
1127
|
+
kind: "array",
|
|
1128
|
+
before,
|
|
1129
|
+
after
|
|
1130
|
+
});
|
|
1131
|
+
setPatchLeaf(patch, field.path, arrayPatch);
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Produces a `TArrayPatch` value for one array field, or `undefined` when no
|
|
1135
|
+
* real op results (the caller then skips the field entirely).
|
|
1136
|
+
*
|
|
1137
|
+
* - Keyed arrays (item object has `@expect.array.key`): emit `$update`
|
|
1138
|
+
* (key + changed leaves), `$insert` (wholly-new items, whole), `$remove`
|
|
1139
|
+
* (key only). Reorder-only (same key membership, same content, different
|
|
1140
|
+
* order) → `$replace` (key-ops can't express a pure reorder).
|
|
1141
|
+
* Ambiguous keys (duplicate or missing key values) → `$replace` (the only
|
|
1142
|
+
* faithful op when key identity is unreliable).
|
|
1143
|
+
* - Unkeyed object arrays / primitive arrays: `$replace` with the whole array.
|
|
1144
|
+
* Primitive arrays with `@expect.array.uniqueItems` use by-value
|
|
1145
|
+
* `$insert` / `$remove` (set semantics).
|
|
1146
|
+
*
|
|
1147
|
+
* Deliberate `$insert`-not-`$upsert`: wholly-new keyed items use `$insert`
|
|
1148
|
+
* (pure append) rather than `$upsert`. This is safe because `$insert` is only
|
|
1149
|
+
* ever used for keys ABSENT from baseline; existing keys go through `$update`.
|
|
1150
|
+
* `$upsert` would dedupe-by-key, which we don't need given that invariant.
|
|
1151
|
+
*/
|
|
1152
|
+
function diffArray(field, before, after) {
|
|
1153
|
+
const beforeArr = Array.isArray(before) ? before : [];
|
|
1154
|
+
const afterArr = Array.isArray(after) ? after : [];
|
|
1155
|
+
const keyProps = getArrayKeyProps(field.itemType);
|
|
1156
|
+
if (keyProps.length > 0) return diffKeyedArray(beforeArr, afterArr, keyProps);
|
|
1157
|
+
if (getFieldMeta(field.prop, "expect.array.uniqueItems") !== void 0 && isPrimitiveItem(field)) {
|
|
1158
|
+
const setPatch = diffUniqueArray(beforeArr, afterArr);
|
|
1159
|
+
if (setPatch) return setPatch;
|
|
1160
|
+
}
|
|
1161
|
+
return { $replace: afterArr };
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Keyed array diff. Reorder-only (same membership) falls back to $replace.
|
|
1165
|
+
*
|
|
1166
|
+
* Returns `undefined` when no $update/$insert/$remove op is produced (so the
|
|
1167
|
+
* caller skips the field rather than emitting a malformed empty `{}`).
|
|
1168
|
+
*
|
|
1169
|
+
* Ambiguity fallback: if either side has DUPLICATE key buckets, or ANY element
|
|
1170
|
+
* is missing all of its key values, key identity is unreliable — fall back to
|
|
1171
|
+
* `{ $replace: after }`, the only faithful op (last-write-wins collapse would
|
|
1172
|
+
* silently drop items, and a key-less `$update` is unmatchable by the DB).
|
|
1173
|
+
*/
|
|
1174
|
+
function diffKeyedArray(before, after, keyProps) {
|
|
1175
|
+
if (hasKeylessItem(before, keyProps) || hasKeylessItem(after, keyProps)) return { $replace: after };
|
|
1176
|
+
const beforeByKey = /* @__PURE__ */ new Map();
|
|
1177
|
+
for (const el of before) if (isPlainObject(el)) beforeByKey.set(keyOf(el, keyProps), el);
|
|
1178
|
+
const afterByKey = /* @__PURE__ */ new Map();
|
|
1179
|
+
for (const el of after) if (isPlainObject(el)) afterByKey.set(keyOf(el, keyProps), el);
|
|
1180
|
+
if (beforeByKey.size !== before.length || afterByKey.size !== after.length) return { $replace: after };
|
|
1181
|
+
if (beforeByKey.size === afterByKey.size) {
|
|
1182
|
+
let sameMembershipAndContent = true;
|
|
1183
|
+
for (const [k, el] of afterByKey) {
|
|
1184
|
+
const prev = beforeByKey.get(k);
|
|
1185
|
+
if (prev === void 0 || !deepEqual(prev, el)) {
|
|
1186
|
+
sameMembershipAndContent = false;
|
|
1187
|
+
break;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
if (sameMembershipAndContent) return { $replace: after };
|
|
1191
|
+
}
|
|
1192
|
+
const $insert = [];
|
|
1193
|
+
const $update = [];
|
|
1194
|
+
const $remove = [];
|
|
1195
|
+
for (const [k, el] of afterByKey) {
|
|
1196
|
+
const prev = beforeByKey.get(k);
|
|
1197
|
+
if (prev === void 0) {
|
|
1198
|
+
$insert.push(el);
|
|
1199
|
+
continue;
|
|
1200
|
+
}
|
|
1201
|
+
if (!deepEqual(prev, el)) {
|
|
1202
|
+
const partial = buildKeyedUpdate(prev, el, keyProps);
|
|
1203
|
+
if (partial) $update.push(partial);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
for (const [k, el] of beforeByKey) if (!afterByKey.has(k)) $remove.push(pickKeys(el, keyProps));
|
|
1207
|
+
return arrayOps({
|
|
1208
|
+
$update,
|
|
1209
|
+
$insert,
|
|
1210
|
+
$remove
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
/** Builds a `$update` partial: key fields + changed leaves only. */
|
|
1214
|
+
function buildKeyedUpdate(prev, next, keyProps) {
|
|
1215
|
+
const partial = {};
|
|
1216
|
+
for (const k of keyProps) partial[k] = next[k];
|
|
1217
|
+
let changed = false;
|
|
1218
|
+
const allKeys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
|
1219
|
+
for (const k of allKeys) {
|
|
1220
|
+
if (keyProps.includes(k)) continue;
|
|
1221
|
+
const a = prev[k];
|
|
1222
|
+
const b = next[k];
|
|
1223
|
+
if (!deepEqual(a, b)) {
|
|
1224
|
+
partial[k] = b === void 0 ? null : b;
|
|
1225
|
+
changed = true;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return changed ? partial : void 0;
|
|
1229
|
+
}
|
|
1230
|
+
/** Primitive uniqueItems set diff. Returns undefined if neither side differs. */
|
|
1231
|
+
function diffUniqueArray(before, after) {
|
|
1232
|
+
const beforeSet = new Set(before.map((v) => stableKey(v)));
|
|
1233
|
+
const afterSet = new Set(after.map((v) => stableKey(v)));
|
|
1234
|
+
const $insert = [];
|
|
1235
|
+
const $remove = [];
|
|
1236
|
+
const seenInsert = /* @__PURE__ */ new Set();
|
|
1237
|
+
const seenRemove = /* @__PURE__ */ new Set();
|
|
1238
|
+
for (const v of after) {
|
|
1239
|
+
const k = stableKey(v);
|
|
1240
|
+
if (!beforeSet.has(k) && !seenInsert.has(k)) {
|
|
1241
|
+
$insert.push(v);
|
|
1242
|
+
seenInsert.add(k);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
for (const v of before) {
|
|
1246
|
+
const k = stableKey(v);
|
|
1247
|
+
if (!afterSet.has(k) && !seenRemove.has(k)) {
|
|
1248
|
+
$remove.push(v);
|
|
1249
|
+
seenRemove.add(k);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return arrayOps({
|
|
1253
|
+
$insert,
|
|
1254
|
+
$remove
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Assembles a `TArrayPatch` from named op-arrays, dropping empty ones. Returns
|
|
1259
|
+
* `undefined` when no op carries any item, so the caller skips the field rather
|
|
1260
|
+
* than emitting a malformed empty `{}`.
|
|
1261
|
+
*/
|
|
1262
|
+
function arrayOps(ops) {
|
|
1263
|
+
const result = {};
|
|
1264
|
+
for (const k in ops) if (ops[k].length > 0) result[k] = ops[k];
|
|
1265
|
+
return Object.keys(result).length > 0 ? result : void 0;
|
|
1266
|
+
}
|
|
1267
|
+
/** Reads `@expect.array.key` props from an array item's object type. */
|
|
1268
|
+
function getArrayKeyProps(itemType) {
|
|
1269
|
+
if (itemType.type.kind !== "object") return [];
|
|
1270
|
+
const props = itemType.type.props;
|
|
1271
|
+
const keys = [];
|
|
1272
|
+
for (const [name, prop] of props.entries()) if (getFieldMeta(prop, "expect.array.key") !== void 0) keys.push(name);
|
|
1273
|
+
return keys;
|
|
1274
|
+
}
|
|
1275
|
+
/** True when the array's item type is a primitive (designType, kind === ''). */
|
|
1276
|
+
function isPrimitiveItem(field) {
|
|
1277
|
+
return field.itemType.type.kind === "";
|
|
1278
|
+
}
|
|
1279
|
+
/** Finds the form's `@db.column.version` column name, if any (top-level only). */
|
|
1280
|
+
function findVersionColumn(def) {
|
|
1281
|
+
for (const [path, prop] of def.flatMap.entries()) {
|
|
1282
|
+
if (!path || path.includes(".")) continue;
|
|
1283
|
+
if (getFieldMeta(prop, "db.column.version") !== void 0) return path;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
/** True when any element lacks ALL of its key values (un-keyable identity). */
|
|
1287
|
+
function hasKeylessItem(arr, keyProps) {
|
|
1288
|
+
for (const el of arr) {
|
|
1289
|
+
if (!isPlainObject(el)) return true;
|
|
1290
|
+
let hasAnyKey = false;
|
|
1291
|
+
for (const k of keyProps) {
|
|
1292
|
+
const v = el[k];
|
|
1293
|
+
if (v !== void 0 && v !== null) {
|
|
1294
|
+
hasAnyKey = true;
|
|
1295
|
+
break;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
if (!hasAnyKey) return true;
|
|
1299
|
+
}
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1302
|
+
/** Composite key string for a keyed-array element. */
|
|
1303
|
+
function keyOf(el, keyProps) {
|
|
1304
|
+
if (keyProps.length === 1) return stableKey(el[keyProps[0]]);
|
|
1305
|
+
return keyProps.map((k) => stableKey(el[k])).join(" ");
|
|
1306
|
+
}
|
|
1307
|
+
/** Picks only the key fields from an element (for $remove). */
|
|
1308
|
+
function pickKeys(el, keyProps) {
|
|
1309
|
+
const out = {};
|
|
1310
|
+
for (const k of keyProps) out[k] = el[k];
|
|
1311
|
+
return out;
|
|
1312
|
+
}
|
|
1313
|
+
/** Writes a value into a (possibly dotted) leaf path on a local patch object. */
|
|
1314
|
+
function setPatchLeaf(patch, path, value) {
|
|
1315
|
+
if (!path.includes(".")) {
|
|
1316
|
+
patch[path] = value;
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
const keys = path.split(".");
|
|
1320
|
+
const last = keys.pop();
|
|
1321
|
+
let cur = patch;
|
|
1322
|
+
for (const k of keys) {
|
|
1323
|
+
let next = cur[k];
|
|
1324
|
+
if (next === void 0 || next === null || typeof next !== "object") {
|
|
1325
|
+
next = {};
|
|
1326
|
+
cur[k] = next;
|
|
1327
|
+
}
|
|
1328
|
+
cur = next;
|
|
1329
|
+
}
|
|
1330
|
+
cur[last] = value;
|
|
1331
|
+
}
|
|
1332
|
+
function isPlainObject(v) {
|
|
1333
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Stable string key for primitives / values (used for set membership).
|
|
1337
|
+
*
|
|
1338
|
+
* Key equality is TYPE-STRICT: a number `1` and a string `'1'` produce
|
|
1339
|
+
* distinct keys, so a keyed-array item whose key changes JS representation
|
|
1340
|
+
* between baseline and current is treated as a remove + insert. Callers that
|
|
1341
|
+
* round-trip keys with loose typing should normalise the key type first.
|
|
1342
|
+
*/
|
|
1343
|
+
function stableKey(v) {
|
|
1344
|
+
if (typeof v === "string") return `s:${v}`;
|
|
1345
|
+
if (typeof v === "number" || typeof v === "boolean") return `p:${String(v)}`;
|
|
1346
|
+
if (v === null) return "null";
|
|
1347
|
+
if (v === void 0) return "undef";
|
|
1348
|
+
return `j:${JSON.stringify(v)}`;
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* Structural deep equality (order-sensitive for arrays). `NaN` equals `NaN`
|
|
1352
|
+
* (revert-aware for NaN scalars) while `0` / `-0` stay equal (matches DB
|
|
1353
|
+
* intent — `===` treats them equal, only NaN is special-cased).
|
|
1354
|
+
*/
|
|
1355
|
+
function deepEqual(a, b) {
|
|
1356
|
+
if (a === b) return true;
|
|
1357
|
+
if (typeof a === "number" && typeof b === "number") return Number.isNaN(a) && Number.isNaN(b);
|
|
1358
|
+
if (a === null || b === null || a === void 0 || b === void 0) return false;
|
|
1359
|
+
if (typeof a !== "object" || typeof b !== "object") return false;
|
|
1360
|
+
const aIsArr = Array.isArray(a);
|
|
1361
|
+
const bIsArr = Array.isArray(b);
|
|
1362
|
+
if (aIsArr !== bIsArr) return false;
|
|
1363
|
+
if (aIsArr && bIsArr) {
|
|
1364
|
+
if (a.length !== b.length) return false;
|
|
1365
|
+
for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
|
|
1366
|
+
return true;
|
|
1367
|
+
}
|
|
1368
|
+
const ao = a;
|
|
1369
|
+
const bo = b;
|
|
1370
|
+
const aKeys = Object.keys(ao);
|
|
1371
|
+
const bKeys = Object.keys(bo);
|
|
1372
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
1373
|
+
for (const k of aKeys) {
|
|
1374
|
+
if (!Object.prototype.hasOwnProperty.call(bo, k)) return false;
|
|
1375
|
+
if (!deepEqual(ao[k], bo[k])) return false;
|
|
1376
|
+
}
|
|
1377
|
+
return true;
|
|
1378
|
+
}
|
|
1379
|
+
//#endregion
|
|
951
1380
|
//#region src/form/error-utils.ts
|
|
952
1381
|
/**
|
|
953
1382
|
* Framework-agnostic helpers for working with form-error maps keyed by
|
|
@@ -1636,6 +2065,7 @@ exports.ValueHelpClient = ValueHelpClient;
|
|
|
1636
2065
|
exports.WF_ACTION_WITH_DATA = WF_ACTION_WITH_DATA;
|
|
1637
2066
|
exports.asArray = asArray;
|
|
1638
2067
|
exports.buildDescendantErrorCounts = buildDescendantErrorCounts;
|
|
2068
|
+
exports.buildFormDiff = buildFormDiff;
|
|
1639
2069
|
exports.buildGridClasses = buildGridClasses;
|
|
1640
2070
|
exports.buildUnionVariants = buildUnionVariants;
|
|
1641
2071
|
exports.createFieldValidator = createFieldValidator;
|
package/dist/index.d.cts
CHANGED
|
@@ -484,6 +484,73 @@ declare function createFieldValidator(prop: TAtscriptAnnotatedType, opts?: TFiel
|
|
|
484
484
|
context: unknown;
|
|
485
485
|
}) => true | string;
|
|
486
486
|
//#endregion
|
|
487
|
+
//#region src/form/diff.d.ts
|
|
488
|
+
/**
|
|
489
|
+
* One field that differs between baseline and current.
|
|
490
|
+
*
|
|
491
|
+
* - `kind: 'set'` — scalar / object / union / tuple field whose value changed
|
|
492
|
+
* (including a clear-to-`null`). `before` / `after` are the whole values at
|
|
493
|
+
* `path`.
|
|
494
|
+
* - `kind: 'array'` — array field whose membership or item content changed.
|
|
495
|
+
* `before` / `after` are the whole arrays.
|
|
496
|
+
*
|
|
497
|
+
* NOTE: `before` / `after` hold live references into the supplied `baseline` /
|
|
498
|
+
* `current` containers — see {@link buildFormDiff} for the snapshot contract.
|
|
499
|
+
*/
|
|
500
|
+
interface FormFieldChange {
|
|
501
|
+
/** Dot-separated path relative to the form root (matches FormFieldDef.path). */
|
|
502
|
+
path: string;
|
|
503
|
+
kind: "set" | "array";
|
|
504
|
+
before: unknown;
|
|
505
|
+
after: unknown;
|
|
506
|
+
}
|
|
507
|
+
/** Options for {@link buildFormDiff}. */
|
|
508
|
+
interface FormDiffOptions {
|
|
509
|
+
/**
|
|
510
|
+
* Optimistic-concurrency control. When `true` (default), a top-level
|
|
511
|
+
* `$cas: { [versionColumn]: baselineVersion }` sibling is auto-included in
|
|
512
|
+
* the patch whenever the form has a `@db.column.version` column AND the
|
|
513
|
+
* patch is non-empty AND a baseline version value exists. `false` suppresses
|
|
514
|
+
* it entirely.
|
|
515
|
+
*
|
|
516
|
+
* Independent of `$cas`, the `@db.column.version` column is ALWAYS excluded
|
|
517
|
+
* from the SET diff: it is server-managed, and a direct write to it is
|
|
518
|
+
* rejected by `@atscript/db` (`DbError('VERSION_COLUMN_WRITE')`). It is only
|
|
519
|
+
* ever round-tripped through `$cas`.
|
|
520
|
+
*/
|
|
521
|
+
cas?: boolean;
|
|
522
|
+
}
|
|
523
|
+
/** Result of {@link buildFormDiff}. */
|
|
524
|
+
interface FormDiffResult {
|
|
525
|
+
/** True when at least one field changed (revert-aware). */
|
|
526
|
+
isDirty: boolean;
|
|
527
|
+
/** Per-field changes (revert-aware — reverted fields are absent). */
|
|
528
|
+
changes: FormFieldChange[];
|
|
529
|
+
/**
|
|
530
|
+
* `@atscript/db` patch object — flat, keyed by field name. Empty `{}` when
|
|
531
|
+
* nothing changed. Carries a top-level `$cas` sibling when `opts.cas` is on
|
|
532
|
+
* and a version column exists.
|
|
533
|
+
*/
|
|
534
|
+
patch: Record<string, unknown>;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Diffs a form's `current` data against its `baseline` snapshot, producing both
|
|
538
|
+
* a changed-fields list and an `@atscript/db` patch object.
|
|
539
|
+
*
|
|
540
|
+
* Both `baseline` and `current` are the WRAPPED form-data container
|
|
541
|
+
* (`{ value: domainData }`) so this reuses {@link getByPath}.
|
|
542
|
+
*
|
|
543
|
+
* Revert-aware: a value edited back to its baseline produces no change and no
|
|
544
|
+
* patch entry.
|
|
545
|
+
*
|
|
546
|
+
* Snapshot contract: the result is NOT a deep copy. `$insert` items, `$replace`
|
|
547
|
+
* arrays, scalar leaf values, and `changes[].before/after` all hold live
|
|
548
|
+
* references into `baseline` / `current`. Callers that keep editing the form
|
|
549
|
+
* after building the patch must snapshot first (e.g. build the patch at submit
|
|
550
|
+
* time on a frozen clone). This is the common Vue v-model flow.
|
|
551
|
+
*/
|
|
552
|
+
declare function buildFormDiff(def: FormDef, baseline: Record<string, unknown>, current: Record<string, unknown>, opts?: FormDiffOptions): FormDiffResult;
|
|
553
|
+
//#endregion
|
|
487
554
|
//#region src/form/error-utils.d.ts
|
|
488
555
|
/**
|
|
489
556
|
* Framework-agnostic helpers for working with form-error maps keyed by
|
|
@@ -912,4 +979,4 @@ declare function getFilterableColumns(def: TableDef): ColumnDef[];
|
|
|
912
979
|
/** Find a column by path. */
|
|
913
980
|
declare function getColumn(def: TableDef, path: string): ColumnDef | undefined;
|
|
914
981
|
//#endregion
|
|
915
|
-
export { type ClientFactory, type ColumnDef, type CurrencyDisplay, DB_AMOUNT_CURRENCY, DB_AMOUNT_CURRENCY_REF, DB_COLUMN_PRECISION, DB_HTTP_PATH, DB_REL_FK, DB_UNIT, DB_UNIT_REF, DEFAULT_COL_SPAN, DEFAULT_ROW_SPAN, type DecimalParts, EXPECT_MAX_LENGTH, type FieldMeta, type FieldResolver, type FormActionInfo, type FormArrayFieldDef, type FormDef, type FormFieldDef, type FormObjectFieldDef, type FormTupleFieldDef, type FormUnionFieldDef, type FormUnionVariant, type FormatDecimalOptions, type GridSpanArgs, type GridSpec, META_DEFAULT, META_DESCRIPTION, META_ID, META_LABEL, META_READONLY, META_REQUIRED, META_SENSITIVE, type MeasurementInfo, type MetaCacheEntry, type MetaResponse, type PaginationControl, type RelationInfo, type ResolvedValueHelp, type SearchIndexInfo, type SortControl, StaticFieldResolver, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionIntent, type TDbActionLevel, type TDbActionProcessor, type TFieldValidatorOptions, type TFormAction, type TFormEntryOptions, type TFormValidatorCallOptions, type TFormValueResolver, type TResolveOptions, type TableActionsModel, type TableDef, type TableQueryState, UI_DICT_ATTR, UI_DICT_DESCR, UI_DICT_FILTERABLE, UI_DICT_LABEL, UI_DICT_SEARCHABLE, UI_DICT_SORTABLE, UI_FORM_ACTION, UI_FORM_ATTR, UI_FORM_AUTOCOMPLETE, UI_FORM_CLASSES, UI_FORM_COMPONENT, UI_FORM_DISABLED, UI_FORM_FN_ATTR, UI_FORM_FN_CLASSES, UI_FORM_FN_DESCRIPTION, UI_FORM_FN_DISABLED, UI_FORM_FN_HIDDEN, UI_FORM_FN_HINT, UI_FORM_FN_LABEL, UI_FORM_FN_OPTIONS, UI_FORM_FN_PLACEHOLDER, UI_FORM_FN_PREFIX, UI_FORM_FN_READONLY, UI_FORM_FN_STYLES, UI_FORM_FN_SUBMIT_DISABLED, UI_FORM_FN_SUBMIT_TEXT, UI_FORM_FN_TITLE, UI_FORM_FN_VALUE, UI_FORM_GRID_COL_SPAN, UI_FORM_GRID_ROW_SPAN, UI_FORM_HIDDEN, UI_FORM_HINT, UI_FORM_LABEL_SINGULAR, UI_FORM_OPTIONS, UI_FORM_ORDER, UI_FORM_PLACEHOLDER, UI_FORM_PREFIX, UI_FORM_PREFIX_ICON, UI_FORM_PREFIX_REF, UI_FORM_STYLES, UI_FORM_SUBMIT_TEXT, UI_FORM_SUFFIX, UI_FORM_SUFFIX_ICON, UI_FORM_SUFFIX_REF, UI_FORM_TYPE, UI_FORM_VALIDATE, UI_TABLE_ATTR, UI_TABLE_CLASSES, UI_TABLE_COMPONENT, UI_TABLE_FN_ATTR, UI_TABLE_FN_CLASSES, UI_TABLE_FN_PREFIX, UI_TABLE_FN_STYLES, UI_TABLE_HIDDEN, UI_TABLE_ORDER, UI_TABLE_SELECT_WITH, UI_TABLE_STYLES, UI_TABLE_TYPE, UI_TABLE_WIDTH, UI_TYPE, ValueHelpClient, type ValueHelpInfo, type ValueHelpResult, type ValueHelpSearchOptions, WF_ACTION_WITH_DATA, asArray, buildDescendantErrorCounts, buildGridClasses, buildUnionVariants, createFieldValidator, createFormData, createFormDef, createFormValueResolver, createTableDef, defaultResolver, detectUnionVariant, enforceScale, extractLiteralOptions, extractMeasurement, extractValueHelp, formatDecimalForDisplay, getByPath, getColumn, getCurrencyDecimals, getCurrencyDisplayParts, getDecimalSeparator, getDeclaredFormActions, getDefaultClientFactory, getDefaultValidatorPlugins, getFieldMeta, getFilterableColumns, getFormValidator, getMetaEntry, getResolver, getSortableColumns, getThousandsSeparator, getVisibleColumns, groupInteger, hasComputedAnnotations, isArrayField, isObjectField, isPureLiteralUnion, isTupleField, isUnionField, iteratePathAncestors, joinDecimalString, mergeErrorMaps, optKey, optLabel, parseColSpan, parseDecimalInput, parseRowSpan, parseStaticAttrs, parseStaticOptions, resetDefaultClientFactory, resetMetaCache, resetValueHelpCache, resolveAttrs, resolveFieldProp, resolveFormProp, resolveGridSpec, resolveOptions, resolveSingularLabel, resolveStatic, resolveValueHelp, setByPath, setDefaultClientFactory, setDefaultValidatorPlugins, setResolver, splitDecimalString, str, valueHelpDictPaths };
|
|
982
|
+
export { type ClientFactory, type ColumnDef, type CurrencyDisplay, DB_AMOUNT_CURRENCY, DB_AMOUNT_CURRENCY_REF, DB_COLUMN_PRECISION, DB_HTTP_PATH, DB_REL_FK, DB_UNIT, DB_UNIT_REF, DEFAULT_COL_SPAN, DEFAULT_ROW_SPAN, type DecimalParts, EXPECT_MAX_LENGTH, type FieldMeta, type FieldResolver, type FormActionInfo, type FormArrayFieldDef, type FormDef, type FormDiffOptions, type FormDiffResult, type FormFieldChange, type FormFieldDef, type FormObjectFieldDef, type FormTupleFieldDef, type FormUnionFieldDef, type FormUnionVariant, type FormatDecimalOptions, type GridSpanArgs, type GridSpec, META_DEFAULT, META_DESCRIPTION, META_ID, META_LABEL, META_READONLY, META_REQUIRED, META_SENSITIVE, type MeasurementInfo, type MetaCacheEntry, type MetaResponse, type PaginationControl, type RelationInfo, type ResolvedValueHelp, type SearchIndexInfo, type SortControl, StaticFieldResolver, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionIntent, type TDbActionLevel, type TDbActionProcessor, type TFieldValidatorOptions, type TFormAction, type TFormEntryOptions, type TFormValidatorCallOptions, type TFormValueResolver, type TResolveOptions, type TableActionsModel, type TableDef, type TableQueryState, UI_DICT_ATTR, UI_DICT_DESCR, UI_DICT_FILTERABLE, UI_DICT_LABEL, UI_DICT_SEARCHABLE, UI_DICT_SORTABLE, UI_FORM_ACTION, UI_FORM_ATTR, UI_FORM_AUTOCOMPLETE, UI_FORM_CLASSES, UI_FORM_COMPONENT, UI_FORM_DISABLED, UI_FORM_FN_ATTR, UI_FORM_FN_CLASSES, UI_FORM_FN_DESCRIPTION, UI_FORM_FN_DISABLED, UI_FORM_FN_HIDDEN, UI_FORM_FN_HINT, UI_FORM_FN_LABEL, UI_FORM_FN_OPTIONS, UI_FORM_FN_PLACEHOLDER, UI_FORM_FN_PREFIX, UI_FORM_FN_READONLY, UI_FORM_FN_STYLES, UI_FORM_FN_SUBMIT_DISABLED, UI_FORM_FN_SUBMIT_TEXT, UI_FORM_FN_TITLE, UI_FORM_FN_VALUE, UI_FORM_GRID_COL_SPAN, UI_FORM_GRID_ROW_SPAN, UI_FORM_HIDDEN, UI_FORM_HINT, UI_FORM_LABEL_SINGULAR, UI_FORM_OPTIONS, UI_FORM_ORDER, UI_FORM_PLACEHOLDER, UI_FORM_PREFIX, UI_FORM_PREFIX_ICON, UI_FORM_PREFIX_REF, UI_FORM_STYLES, UI_FORM_SUBMIT_TEXT, UI_FORM_SUFFIX, UI_FORM_SUFFIX_ICON, UI_FORM_SUFFIX_REF, UI_FORM_TYPE, UI_FORM_VALIDATE, UI_TABLE_ATTR, UI_TABLE_CLASSES, UI_TABLE_COMPONENT, UI_TABLE_FN_ATTR, UI_TABLE_FN_CLASSES, UI_TABLE_FN_PREFIX, UI_TABLE_FN_STYLES, UI_TABLE_HIDDEN, UI_TABLE_ORDER, UI_TABLE_SELECT_WITH, UI_TABLE_STYLES, UI_TABLE_TYPE, UI_TABLE_WIDTH, UI_TYPE, ValueHelpClient, type ValueHelpInfo, type ValueHelpResult, type ValueHelpSearchOptions, WF_ACTION_WITH_DATA, asArray, buildDescendantErrorCounts, buildFormDiff, buildGridClasses, buildUnionVariants, createFieldValidator, createFormData, createFormDef, createFormValueResolver, createTableDef, defaultResolver, detectUnionVariant, enforceScale, extractLiteralOptions, extractMeasurement, extractValueHelp, formatDecimalForDisplay, getByPath, getColumn, getCurrencyDecimals, getCurrencyDisplayParts, getDecimalSeparator, getDeclaredFormActions, getDefaultClientFactory, getDefaultValidatorPlugins, getFieldMeta, getFilterableColumns, getFormValidator, getMetaEntry, getResolver, getSortableColumns, getThousandsSeparator, getVisibleColumns, groupInteger, hasComputedAnnotations, isArrayField, isObjectField, isPureLiteralUnion, isTupleField, isUnionField, iteratePathAncestors, joinDecimalString, mergeErrorMaps, optKey, optLabel, parseColSpan, parseDecimalInput, parseRowSpan, parseStaticAttrs, parseStaticOptions, resetDefaultClientFactory, resetMetaCache, resetValueHelpCache, resolveAttrs, resolveFieldProp, resolveFormProp, resolveGridSpec, resolveOptions, resolveSingularLabel, resolveStatic, resolveValueHelp, setByPath, setDefaultClientFactory, setDefaultValidatorPlugins, setResolver, splitDecimalString, str, valueHelpDictPaths };
|
package/dist/index.d.mts
CHANGED
|
@@ -484,6 +484,73 @@ declare function createFieldValidator(prop: TAtscriptAnnotatedType, opts?: TFiel
|
|
|
484
484
|
context: unknown;
|
|
485
485
|
}) => true | string;
|
|
486
486
|
//#endregion
|
|
487
|
+
//#region src/form/diff.d.ts
|
|
488
|
+
/**
|
|
489
|
+
* One field that differs between baseline and current.
|
|
490
|
+
*
|
|
491
|
+
* - `kind: 'set'` — scalar / object / union / tuple field whose value changed
|
|
492
|
+
* (including a clear-to-`null`). `before` / `after` are the whole values at
|
|
493
|
+
* `path`.
|
|
494
|
+
* - `kind: 'array'` — array field whose membership or item content changed.
|
|
495
|
+
* `before` / `after` are the whole arrays.
|
|
496
|
+
*
|
|
497
|
+
* NOTE: `before` / `after` hold live references into the supplied `baseline` /
|
|
498
|
+
* `current` containers — see {@link buildFormDiff} for the snapshot contract.
|
|
499
|
+
*/
|
|
500
|
+
interface FormFieldChange {
|
|
501
|
+
/** Dot-separated path relative to the form root (matches FormFieldDef.path). */
|
|
502
|
+
path: string;
|
|
503
|
+
kind: "set" | "array";
|
|
504
|
+
before: unknown;
|
|
505
|
+
after: unknown;
|
|
506
|
+
}
|
|
507
|
+
/** Options for {@link buildFormDiff}. */
|
|
508
|
+
interface FormDiffOptions {
|
|
509
|
+
/**
|
|
510
|
+
* Optimistic-concurrency control. When `true` (default), a top-level
|
|
511
|
+
* `$cas: { [versionColumn]: baselineVersion }` sibling is auto-included in
|
|
512
|
+
* the patch whenever the form has a `@db.column.version` column AND the
|
|
513
|
+
* patch is non-empty AND a baseline version value exists. `false` suppresses
|
|
514
|
+
* it entirely.
|
|
515
|
+
*
|
|
516
|
+
* Independent of `$cas`, the `@db.column.version` column is ALWAYS excluded
|
|
517
|
+
* from the SET diff: it is server-managed, and a direct write to it is
|
|
518
|
+
* rejected by `@atscript/db` (`DbError('VERSION_COLUMN_WRITE')`). It is only
|
|
519
|
+
* ever round-tripped through `$cas`.
|
|
520
|
+
*/
|
|
521
|
+
cas?: boolean;
|
|
522
|
+
}
|
|
523
|
+
/** Result of {@link buildFormDiff}. */
|
|
524
|
+
interface FormDiffResult {
|
|
525
|
+
/** True when at least one field changed (revert-aware). */
|
|
526
|
+
isDirty: boolean;
|
|
527
|
+
/** Per-field changes (revert-aware — reverted fields are absent). */
|
|
528
|
+
changes: FormFieldChange[];
|
|
529
|
+
/**
|
|
530
|
+
* `@atscript/db` patch object — flat, keyed by field name. Empty `{}` when
|
|
531
|
+
* nothing changed. Carries a top-level `$cas` sibling when `opts.cas` is on
|
|
532
|
+
* and a version column exists.
|
|
533
|
+
*/
|
|
534
|
+
patch: Record<string, unknown>;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Diffs a form's `current` data against its `baseline` snapshot, producing both
|
|
538
|
+
* a changed-fields list and an `@atscript/db` patch object.
|
|
539
|
+
*
|
|
540
|
+
* Both `baseline` and `current` are the WRAPPED form-data container
|
|
541
|
+
* (`{ value: domainData }`) so this reuses {@link getByPath}.
|
|
542
|
+
*
|
|
543
|
+
* Revert-aware: a value edited back to its baseline produces no change and no
|
|
544
|
+
* patch entry.
|
|
545
|
+
*
|
|
546
|
+
* Snapshot contract: the result is NOT a deep copy. `$insert` items, `$replace`
|
|
547
|
+
* arrays, scalar leaf values, and `changes[].before/after` all hold live
|
|
548
|
+
* references into `baseline` / `current`. Callers that keep editing the form
|
|
549
|
+
* after building the patch must snapshot first (e.g. build the patch at submit
|
|
550
|
+
* time on a frozen clone). This is the common Vue v-model flow.
|
|
551
|
+
*/
|
|
552
|
+
declare function buildFormDiff(def: FormDef, baseline: Record<string, unknown>, current: Record<string, unknown>, opts?: FormDiffOptions): FormDiffResult;
|
|
553
|
+
//#endregion
|
|
487
554
|
//#region src/form/error-utils.d.ts
|
|
488
555
|
/**
|
|
489
556
|
* Framework-agnostic helpers for working with form-error maps keyed by
|
|
@@ -912,4 +979,4 @@ declare function getFilterableColumns(def: TableDef): ColumnDef[];
|
|
|
912
979
|
/** Find a column by path. */
|
|
913
980
|
declare function getColumn(def: TableDef, path: string): ColumnDef | undefined;
|
|
914
981
|
//#endregion
|
|
915
|
-
export { type ClientFactory, type ColumnDef, type CurrencyDisplay, DB_AMOUNT_CURRENCY, DB_AMOUNT_CURRENCY_REF, DB_COLUMN_PRECISION, DB_HTTP_PATH, DB_REL_FK, DB_UNIT, DB_UNIT_REF, DEFAULT_COL_SPAN, DEFAULT_ROW_SPAN, type DecimalParts, EXPECT_MAX_LENGTH, type FieldMeta, type FieldResolver, type FormActionInfo, type FormArrayFieldDef, type FormDef, type FormFieldDef, type FormObjectFieldDef, type FormTupleFieldDef, type FormUnionFieldDef, type FormUnionVariant, type FormatDecimalOptions, type GridSpanArgs, type GridSpec, META_DEFAULT, META_DESCRIPTION, META_ID, META_LABEL, META_READONLY, META_REQUIRED, META_SENSITIVE, type MeasurementInfo, type MetaCacheEntry, type MetaResponse, type PaginationControl, type RelationInfo, type ResolvedValueHelp, type SearchIndexInfo, type SortControl, StaticFieldResolver, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionIntent, type TDbActionLevel, type TDbActionProcessor, type TFieldValidatorOptions, type TFormAction, type TFormEntryOptions, type TFormValidatorCallOptions, type TFormValueResolver, type TResolveOptions, type TableActionsModel, type TableDef, type TableQueryState, UI_DICT_ATTR, UI_DICT_DESCR, UI_DICT_FILTERABLE, UI_DICT_LABEL, UI_DICT_SEARCHABLE, UI_DICT_SORTABLE, UI_FORM_ACTION, UI_FORM_ATTR, UI_FORM_AUTOCOMPLETE, UI_FORM_CLASSES, UI_FORM_COMPONENT, UI_FORM_DISABLED, UI_FORM_FN_ATTR, UI_FORM_FN_CLASSES, UI_FORM_FN_DESCRIPTION, UI_FORM_FN_DISABLED, UI_FORM_FN_HIDDEN, UI_FORM_FN_HINT, UI_FORM_FN_LABEL, UI_FORM_FN_OPTIONS, UI_FORM_FN_PLACEHOLDER, UI_FORM_FN_PREFIX, UI_FORM_FN_READONLY, UI_FORM_FN_STYLES, UI_FORM_FN_SUBMIT_DISABLED, UI_FORM_FN_SUBMIT_TEXT, UI_FORM_FN_TITLE, UI_FORM_FN_VALUE, UI_FORM_GRID_COL_SPAN, UI_FORM_GRID_ROW_SPAN, UI_FORM_HIDDEN, UI_FORM_HINT, UI_FORM_LABEL_SINGULAR, UI_FORM_OPTIONS, UI_FORM_ORDER, UI_FORM_PLACEHOLDER, UI_FORM_PREFIX, UI_FORM_PREFIX_ICON, UI_FORM_PREFIX_REF, UI_FORM_STYLES, UI_FORM_SUBMIT_TEXT, UI_FORM_SUFFIX, UI_FORM_SUFFIX_ICON, UI_FORM_SUFFIX_REF, UI_FORM_TYPE, UI_FORM_VALIDATE, UI_TABLE_ATTR, UI_TABLE_CLASSES, UI_TABLE_COMPONENT, UI_TABLE_FN_ATTR, UI_TABLE_FN_CLASSES, UI_TABLE_FN_PREFIX, UI_TABLE_FN_STYLES, UI_TABLE_HIDDEN, UI_TABLE_ORDER, UI_TABLE_SELECT_WITH, UI_TABLE_STYLES, UI_TABLE_TYPE, UI_TABLE_WIDTH, UI_TYPE, ValueHelpClient, type ValueHelpInfo, type ValueHelpResult, type ValueHelpSearchOptions, WF_ACTION_WITH_DATA, asArray, buildDescendantErrorCounts, buildGridClasses, buildUnionVariants, createFieldValidator, createFormData, createFormDef, createFormValueResolver, createTableDef, defaultResolver, detectUnionVariant, enforceScale, extractLiteralOptions, extractMeasurement, extractValueHelp, formatDecimalForDisplay, getByPath, getColumn, getCurrencyDecimals, getCurrencyDisplayParts, getDecimalSeparator, getDeclaredFormActions, getDefaultClientFactory, getDefaultValidatorPlugins, getFieldMeta, getFilterableColumns, getFormValidator, getMetaEntry, getResolver, getSortableColumns, getThousandsSeparator, getVisibleColumns, groupInteger, hasComputedAnnotations, isArrayField, isObjectField, isPureLiteralUnion, isTupleField, isUnionField, iteratePathAncestors, joinDecimalString, mergeErrorMaps, optKey, optLabel, parseColSpan, parseDecimalInput, parseRowSpan, parseStaticAttrs, parseStaticOptions, resetDefaultClientFactory, resetMetaCache, resetValueHelpCache, resolveAttrs, resolveFieldProp, resolveFormProp, resolveGridSpec, resolveOptions, resolveSingularLabel, resolveStatic, resolveValueHelp, setByPath, setDefaultClientFactory, setDefaultValidatorPlugins, setResolver, splitDecimalString, str, valueHelpDictPaths };
|
|
982
|
+
export { type ClientFactory, type ColumnDef, type CurrencyDisplay, DB_AMOUNT_CURRENCY, DB_AMOUNT_CURRENCY_REF, DB_COLUMN_PRECISION, DB_HTTP_PATH, DB_REL_FK, DB_UNIT, DB_UNIT_REF, DEFAULT_COL_SPAN, DEFAULT_ROW_SPAN, type DecimalParts, EXPECT_MAX_LENGTH, type FieldMeta, type FieldResolver, type FormActionInfo, type FormArrayFieldDef, type FormDef, type FormDiffOptions, type FormDiffResult, type FormFieldChange, type FormFieldDef, type FormObjectFieldDef, type FormTupleFieldDef, type FormUnionFieldDef, type FormUnionVariant, type FormatDecimalOptions, type GridSpanArgs, type GridSpec, META_DEFAULT, META_DESCRIPTION, META_ID, META_LABEL, META_READONLY, META_REQUIRED, META_SENSITIVE, type MeasurementInfo, type MetaCacheEntry, type MetaResponse, type PaginationControl, type RelationInfo, type ResolvedValueHelp, type SearchIndexInfo, type SortControl, StaticFieldResolver, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionIntent, type TDbActionLevel, type TDbActionProcessor, type TFieldValidatorOptions, type TFormAction, type TFormEntryOptions, type TFormValidatorCallOptions, type TFormValueResolver, type TResolveOptions, type TableActionsModel, type TableDef, type TableQueryState, UI_DICT_ATTR, UI_DICT_DESCR, UI_DICT_FILTERABLE, UI_DICT_LABEL, UI_DICT_SEARCHABLE, UI_DICT_SORTABLE, UI_FORM_ACTION, UI_FORM_ATTR, UI_FORM_AUTOCOMPLETE, UI_FORM_CLASSES, UI_FORM_COMPONENT, UI_FORM_DISABLED, UI_FORM_FN_ATTR, UI_FORM_FN_CLASSES, UI_FORM_FN_DESCRIPTION, UI_FORM_FN_DISABLED, UI_FORM_FN_HIDDEN, UI_FORM_FN_HINT, UI_FORM_FN_LABEL, UI_FORM_FN_OPTIONS, UI_FORM_FN_PLACEHOLDER, UI_FORM_FN_PREFIX, UI_FORM_FN_READONLY, UI_FORM_FN_STYLES, UI_FORM_FN_SUBMIT_DISABLED, UI_FORM_FN_SUBMIT_TEXT, UI_FORM_FN_TITLE, UI_FORM_FN_VALUE, UI_FORM_GRID_COL_SPAN, UI_FORM_GRID_ROW_SPAN, UI_FORM_HIDDEN, UI_FORM_HINT, UI_FORM_LABEL_SINGULAR, UI_FORM_OPTIONS, UI_FORM_ORDER, UI_FORM_PLACEHOLDER, UI_FORM_PREFIX, UI_FORM_PREFIX_ICON, UI_FORM_PREFIX_REF, UI_FORM_STYLES, UI_FORM_SUBMIT_TEXT, UI_FORM_SUFFIX, UI_FORM_SUFFIX_ICON, UI_FORM_SUFFIX_REF, UI_FORM_TYPE, UI_FORM_VALIDATE, UI_TABLE_ATTR, UI_TABLE_CLASSES, UI_TABLE_COMPONENT, UI_TABLE_FN_ATTR, UI_TABLE_FN_CLASSES, UI_TABLE_FN_PREFIX, UI_TABLE_FN_STYLES, UI_TABLE_HIDDEN, UI_TABLE_ORDER, UI_TABLE_SELECT_WITH, UI_TABLE_STYLES, UI_TABLE_TYPE, UI_TABLE_WIDTH, UI_TYPE, ValueHelpClient, type ValueHelpInfo, type ValueHelpResult, type ValueHelpSearchOptions, WF_ACTION_WITH_DATA, asArray, buildDescendantErrorCounts, buildFormDiff, buildGridClasses, buildUnionVariants, createFieldValidator, createFormData, createFormDef, createFormValueResolver, createTableDef, defaultResolver, detectUnionVariant, enforceScale, extractLiteralOptions, extractMeasurement, extractValueHelp, formatDecimalForDisplay, getByPath, getColumn, getCurrencyDecimals, getCurrencyDisplayParts, getDecimalSeparator, getDeclaredFormActions, getDefaultClientFactory, getDefaultValidatorPlugins, getFieldMeta, getFilterableColumns, getFormValidator, getMetaEntry, getResolver, getSortableColumns, getThousandsSeparator, getVisibleColumns, groupInteger, hasComputedAnnotations, isArrayField, isObjectField, isPureLiteralUnion, isTupleField, isUnionField, iteratePathAncestors, joinDecimalString, mergeErrorMaps, optKey, optLabel, parseColSpan, parseDecimalInput, parseRowSpan, parseStaticAttrs, parseStaticOptions, resetDefaultClientFactory, resetMetaCache, resetValueHelpCache, resolveAttrs, resolveFieldProp, resolveFormProp, resolveGridSpec, resolveOptions, resolveSingularLabel, resolveStatic, resolveValueHelp, setByPath, setDefaultClientFactory, setDefaultValidatorPlugins, setResolver, splitDecimalString, str, valueHelpDictPaths };
|
package/dist/index.mjs
CHANGED
|
@@ -947,6 +947,435 @@ function createFieldValidator(prop, opts) {
|
|
|
947
947
|
};
|
|
948
948
|
}
|
|
949
949
|
//#endregion
|
|
950
|
+
//#region src/form/diff.ts
|
|
951
|
+
/**
|
|
952
|
+
* Diffs a form's `current` data against its `baseline` snapshot, producing both
|
|
953
|
+
* a changed-fields list and an `@atscript/db` patch object.
|
|
954
|
+
*
|
|
955
|
+
* Both `baseline` and `current` are the WRAPPED form-data container
|
|
956
|
+
* (`{ value: domainData }`) so this reuses {@link getByPath}.
|
|
957
|
+
*
|
|
958
|
+
* Revert-aware: a value edited back to its baseline produces no change and no
|
|
959
|
+
* patch entry.
|
|
960
|
+
*
|
|
961
|
+
* Snapshot contract: the result is NOT a deep copy. `$insert` items, `$replace`
|
|
962
|
+
* arrays, scalar leaf values, and `changes[].before/after` all hold live
|
|
963
|
+
* references into `baseline` / `current`. Callers that keep editing the form
|
|
964
|
+
* after building the patch must snapshot first (e.g. build the patch at submit
|
|
965
|
+
* time on a frozen clone). This is the common Vue v-model flow.
|
|
966
|
+
*/
|
|
967
|
+
function buildFormDiff(def, baseline, current, opts) {
|
|
968
|
+
const changes = [];
|
|
969
|
+
const patch = {};
|
|
970
|
+
const versionColumn = findVersionColumn(def);
|
|
971
|
+
diffFields(def.fields, "", baseline, current, changes, patch, versionColumn, def.flatMap);
|
|
972
|
+
if ((opts?.cas ?? true) && versionColumn && Object.keys(patch).length > 0) {
|
|
973
|
+
const baselineVersion = getByPath(baseline, versionColumn);
|
|
974
|
+
if (typeof baselineVersion === "number" && Number.isInteger(baselineVersion)) patch.$cas = { [versionColumn]: baselineVersion };
|
|
975
|
+
}
|
|
976
|
+
return {
|
|
977
|
+
isDirty: changes.length > 0,
|
|
978
|
+
changes,
|
|
979
|
+
patch
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Diffs a list of sibling fields. `prefix` is the dot-path of the parent
|
|
984
|
+
* context relative to the form root (used only for the change list `path`);
|
|
985
|
+
* patch entries are written into the local `patch` object so callers can place
|
|
986
|
+
* the whole sub-object as a nested partial.
|
|
987
|
+
*
|
|
988
|
+
* `versionColumn` is the top-level `@db.column.version` field name (or
|
|
989
|
+
* undefined). When set, the matching top-level field is skipped entirely — it
|
|
990
|
+
* is server-managed and may only be round-tripped via `$cas`.
|
|
991
|
+
*
|
|
992
|
+
* `inlineFlatMap` is the form's `flatMap`, supplied only at the top-level walk.
|
|
993
|
+
* It lets {@link diffScalarField} read `@db.patch.strategy` off the object
|
|
994
|
+
* ancestors of an INLINED leaf (a dotted-path leaf with no `FormObjectFieldDef`
|
|
995
|
+
* node — `createFormDef` dissolves unlabelled objects into dot-paths). At a
|
|
996
|
+
* default (replace) ancestor the whole sub-object must be emitted. It is
|
|
997
|
+
* intentionally NOT propagated into `diffObjectField` recursion, where the
|
|
998
|
+
* strategy decision is already made per structured object.
|
|
999
|
+
*/
|
|
1000
|
+
function diffFields(fields, prefix, baseline, current, changes, patch, versionColumn, inlineFlatMap) {
|
|
1001
|
+
for (const field of fields) {
|
|
1002
|
+
if (field.path === "" && fields.length === 1) {
|
|
1003
|
+
diffLeafRoot(field, baseline, current, changes, patch);
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
if (field.phantom) continue;
|
|
1007
|
+
if (versionColumn !== void 0 && !prefix && field.path === versionColumn) continue;
|
|
1008
|
+
const fullPath = prefix ? `${prefix}.${field.path}` : field.path;
|
|
1009
|
+
if (isArrayField(field)) {
|
|
1010
|
+
diffArrayField(field, fullPath, baseline, current, changes, patch);
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
if (isObjectField(field)) {
|
|
1014
|
+
diffObjectField(field, fullPath, baseline, current, changes, patch);
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
diffScalarField(field, fullPath, baseline, current, changes, patch, inlineFlatMap);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
/** Single-leaf root form (non-object root). Whole value is the patch. */
|
|
1021
|
+
function diffLeafRoot(field, baseline, current, changes, patch) {
|
|
1022
|
+
const before = getByPath(baseline, "");
|
|
1023
|
+
const after = getByPath(current, "");
|
|
1024
|
+
if (deepEqual(before, after)) return;
|
|
1025
|
+
if (isArrayField(field)) {
|
|
1026
|
+
const arrayPatch = diffArray(field, before, after);
|
|
1027
|
+
if (arrayPatch === void 0) return;
|
|
1028
|
+
changes.push({
|
|
1029
|
+
path: "",
|
|
1030
|
+
kind: "array",
|
|
1031
|
+
before,
|
|
1032
|
+
after
|
|
1033
|
+
});
|
|
1034
|
+
patch.value = arrayPatch;
|
|
1035
|
+
} else {
|
|
1036
|
+
changes.push({
|
|
1037
|
+
path: "",
|
|
1038
|
+
kind: "set",
|
|
1039
|
+
before,
|
|
1040
|
+
after
|
|
1041
|
+
});
|
|
1042
|
+
patch.value = after === void 0 ? null : after;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
/** Scalar / union / tuple / ref field — whole-value compare; clear → null. */
|
|
1046
|
+
function diffScalarField(field, fullPath, baseline, current, changes, patch, inlineFlatMap) {
|
|
1047
|
+
const before = getByPath(baseline, fullPath);
|
|
1048
|
+
const after = getByPath(current, fullPath);
|
|
1049
|
+
if (deepEqual(before, after)) return;
|
|
1050
|
+
changes.push({
|
|
1051
|
+
path: fullPath,
|
|
1052
|
+
kind: "set",
|
|
1053
|
+
before,
|
|
1054
|
+
after
|
|
1055
|
+
});
|
|
1056
|
+
if (inlineFlatMap && field.path.includes(".")) {
|
|
1057
|
+
const cutoff = inlinedReplaceCutoff(field.path, inlineFlatMap);
|
|
1058
|
+
if (cutoff !== void 0) {
|
|
1059
|
+
const prefixLen = fullPath.length - field.path.length;
|
|
1060
|
+
const sub = getByPath(current, prefixLen > 0 ? fullPath.slice(0, prefixLen) + cutoff : cutoff);
|
|
1061
|
+
setPatchLeaf(patch, cutoff, sub === void 0 ? null : sub);
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
setPatchLeaf(patch, field.path, after === void 0 ? null : after);
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* For a dotted INLINED leaf path, returns the path of the shallowest object
|
|
1069
|
+
* ancestor whose `@db.patch.strategy` is the default (`replace`), or undefined
|
|
1070
|
+
* when every ancestor is `merge` (then the leaf partial is correct). At a
|
|
1071
|
+
* replace ancestor the whole sub-object must be present in the patch.
|
|
1072
|
+
*
|
|
1073
|
+
* Walks ancestor segments (`a`, `a.b`, … but not the leaf itself); the first
|
|
1074
|
+
* one that is an object AND not merge is the cutoff. merge does NOT propagate,
|
|
1075
|
+
* so a default-replace level below a merge level still cuts off there.
|
|
1076
|
+
*/
|
|
1077
|
+
function inlinedReplaceCutoff(leafPath, flatMap) {
|
|
1078
|
+
const segs = leafPath.split(".");
|
|
1079
|
+
let acc = "";
|
|
1080
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
1081
|
+
acc = acc ? `${acc}.${segs[i]}` : segs[i];
|
|
1082
|
+
const prop = flatMap.get(acc);
|
|
1083
|
+
if (!prop || prop.type.kind !== "object") continue;
|
|
1084
|
+
if (getFieldMeta(prop, "db.patch.strategy") !== "merge") return acc;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Inlined-or-structured object field — recurse, emit a nested partial OR the
|
|
1089
|
+
* whole sub-object depending on the field's `@db.patch.strategy`.
|
|
1090
|
+
*
|
|
1091
|
+
* atscript-db's DEFAULT nested-object patch strategy is `replace` (strict —
|
|
1092
|
+
* every required child must be present, else 400; omitted optionals are
|
|
1093
|
+
* null-filled). A changed-leaves-only partial is a valid patch ONLY when the
|
|
1094
|
+
* object field carries `@db.patch.strategy 'merge'`. For the default (replace)
|
|
1095
|
+
* case we therefore emit the WHOLE current sub-object so the validator passes
|
|
1096
|
+
* and no optional leaf is silently nulled. `merge` does NOT propagate, so a
|
|
1097
|
+
* descendant object without its own `merge` again emits its full sub-object
|
|
1098
|
+
* (handled by recursion — `diffFields` re-enters this function per child).
|
|
1099
|
+
*
|
|
1100
|
+
* Wholesale-clear: if the sub-object was a defined object in `baseline` but is
|
|
1101
|
+
* now undefined/null, emit `field: null` (object removed) instead of a partial
|
|
1102
|
+
* of nulled leaves.
|
|
1103
|
+
*/
|
|
1104
|
+
function diffObjectField(field, fullPath, baseline, current, changes, patch) {
|
|
1105
|
+
const beforeObj = getByPath(baseline, fullPath);
|
|
1106
|
+
const afterObj = getByPath(current, fullPath);
|
|
1107
|
+
const nested = {};
|
|
1108
|
+
diffFields(field.objectDef.fields, fullPath, baseline, current, changes, nested, void 0, field.objectDef.flatMap);
|
|
1109
|
+
if ((afterObj === void 0 || afterObj === null) && isPlainObject(beforeObj)) {
|
|
1110
|
+
setPatchLeaf(patch, field.path, null);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (Object.keys(nested).length === 0) return;
|
|
1114
|
+
if (getFieldMeta(field.prop, "db.patch.strategy") === "merge") setPatchLeaf(patch, field.path, nested);
|
|
1115
|
+
else setPatchLeaf(patch, field.path, afterObj === void 0 ? null : afterObj);
|
|
1116
|
+
}
|
|
1117
|
+
/** Array field — keyed → $update/$insert/$remove; unkeyed → $replace. */
|
|
1118
|
+
function diffArrayField(field, fullPath, baseline, current, changes, patch) {
|
|
1119
|
+
const before = getByPath(baseline, fullPath);
|
|
1120
|
+
const after = getByPath(current, fullPath);
|
|
1121
|
+
if (deepEqual(before, after)) return;
|
|
1122
|
+
const arrayPatch = diffArray(field, before, after);
|
|
1123
|
+
if (arrayPatch === void 0) return;
|
|
1124
|
+
changes.push({
|
|
1125
|
+
path: fullPath,
|
|
1126
|
+
kind: "array",
|
|
1127
|
+
before,
|
|
1128
|
+
after
|
|
1129
|
+
});
|
|
1130
|
+
setPatchLeaf(patch, field.path, arrayPatch);
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Produces a `TArrayPatch` value for one array field, or `undefined` when no
|
|
1134
|
+
* real op results (the caller then skips the field entirely).
|
|
1135
|
+
*
|
|
1136
|
+
* - Keyed arrays (item object has `@expect.array.key`): emit `$update`
|
|
1137
|
+
* (key + changed leaves), `$insert` (wholly-new items, whole), `$remove`
|
|
1138
|
+
* (key only). Reorder-only (same key membership, same content, different
|
|
1139
|
+
* order) → `$replace` (key-ops can't express a pure reorder).
|
|
1140
|
+
* Ambiguous keys (duplicate or missing key values) → `$replace` (the only
|
|
1141
|
+
* faithful op when key identity is unreliable).
|
|
1142
|
+
* - Unkeyed object arrays / primitive arrays: `$replace` with the whole array.
|
|
1143
|
+
* Primitive arrays with `@expect.array.uniqueItems` use by-value
|
|
1144
|
+
* `$insert` / `$remove` (set semantics).
|
|
1145
|
+
*
|
|
1146
|
+
* Deliberate `$insert`-not-`$upsert`: wholly-new keyed items use `$insert`
|
|
1147
|
+
* (pure append) rather than `$upsert`. This is safe because `$insert` is only
|
|
1148
|
+
* ever used for keys ABSENT from baseline; existing keys go through `$update`.
|
|
1149
|
+
* `$upsert` would dedupe-by-key, which we don't need given that invariant.
|
|
1150
|
+
*/
|
|
1151
|
+
function diffArray(field, before, after) {
|
|
1152
|
+
const beforeArr = Array.isArray(before) ? before : [];
|
|
1153
|
+
const afterArr = Array.isArray(after) ? after : [];
|
|
1154
|
+
const keyProps = getArrayKeyProps(field.itemType);
|
|
1155
|
+
if (keyProps.length > 0) return diffKeyedArray(beforeArr, afterArr, keyProps);
|
|
1156
|
+
if (getFieldMeta(field.prop, "expect.array.uniqueItems") !== void 0 && isPrimitiveItem(field)) {
|
|
1157
|
+
const setPatch = diffUniqueArray(beforeArr, afterArr);
|
|
1158
|
+
if (setPatch) return setPatch;
|
|
1159
|
+
}
|
|
1160
|
+
return { $replace: afterArr };
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Keyed array diff. Reorder-only (same membership) falls back to $replace.
|
|
1164
|
+
*
|
|
1165
|
+
* Returns `undefined` when no $update/$insert/$remove op is produced (so the
|
|
1166
|
+
* caller skips the field rather than emitting a malformed empty `{}`).
|
|
1167
|
+
*
|
|
1168
|
+
* Ambiguity fallback: if either side has DUPLICATE key buckets, or ANY element
|
|
1169
|
+
* is missing all of its key values, key identity is unreliable — fall back to
|
|
1170
|
+
* `{ $replace: after }`, the only faithful op (last-write-wins collapse would
|
|
1171
|
+
* silently drop items, and a key-less `$update` is unmatchable by the DB).
|
|
1172
|
+
*/
|
|
1173
|
+
function diffKeyedArray(before, after, keyProps) {
|
|
1174
|
+
if (hasKeylessItem(before, keyProps) || hasKeylessItem(after, keyProps)) return { $replace: after };
|
|
1175
|
+
const beforeByKey = /* @__PURE__ */ new Map();
|
|
1176
|
+
for (const el of before) if (isPlainObject(el)) beforeByKey.set(keyOf(el, keyProps), el);
|
|
1177
|
+
const afterByKey = /* @__PURE__ */ new Map();
|
|
1178
|
+
for (const el of after) if (isPlainObject(el)) afterByKey.set(keyOf(el, keyProps), el);
|
|
1179
|
+
if (beforeByKey.size !== before.length || afterByKey.size !== after.length) return { $replace: after };
|
|
1180
|
+
if (beforeByKey.size === afterByKey.size) {
|
|
1181
|
+
let sameMembershipAndContent = true;
|
|
1182
|
+
for (const [k, el] of afterByKey) {
|
|
1183
|
+
const prev = beforeByKey.get(k);
|
|
1184
|
+
if (prev === void 0 || !deepEqual(prev, el)) {
|
|
1185
|
+
sameMembershipAndContent = false;
|
|
1186
|
+
break;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
if (sameMembershipAndContent) return { $replace: after };
|
|
1190
|
+
}
|
|
1191
|
+
const $insert = [];
|
|
1192
|
+
const $update = [];
|
|
1193
|
+
const $remove = [];
|
|
1194
|
+
for (const [k, el] of afterByKey) {
|
|
1195
|
+
const prev = beforeByKey.get(k);
|
|
1196
|
+
if (prev === void 0) {
|
|
1197
|
+
$insert.push(el);
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
if (!deepEqual(prev, el)) {
|
|
1201
|
+
const partial = buildKeyedUpdate(prev, el, keyProps);
|
|
1202
|
+
if (partial) $update.push(partial);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
for (const [k, el] of beforeByKey) if (!afterByKey.has(k)) $remove.push(pickKeys(el, keyProps));
|
|
1206
|
+
return arrayOps({
|
|
1207
|
+
$update,
|
|
1208
|
+
$insert,
|
|
1209
|
+
$remove
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
/** Builds a `$update` partial: key fields + changed leaves only. */
|
|
1213
|
+
function buildKeyedUpdate(prev, next, keyProps) {
|
|
1214
|
+
const partial = {};
|
|
1215
|
+
for (const k of keyProps) partial[k] = next[k];
|
|
1216
|
+
let changed = false;
|
|
1217
|
+
const allKeys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
|
1218
|
+
for (const k of allKeys) {
|
|
1219
|
+
if (keyProps.includes(k)) continue;
|
|
1220
|
+
const a = prev[k];
|
|
1221
|
+
const b = next[k];
|
|
1222
|
+
if (!deepEqual(a, b)) {
|
|
1223
|
+
partial[k] = b === void 0 ? null : b;
|
|
1224
|
+
changed = true;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return changed ? partial : void 0;
|
|
1228
|
+
}
|
|
1229
|
+
/** Primitive uniqueItems set diff. Returns undefined if neither side differs. */
|
|
1230
|
+
function diffUniqueArray(before, after) {
|
|
1231
|
+
const beforeSet = new Set(before.map((v) => stableKey(v)));
|
|
1232
|
+
const afterSet = new Set(after.map((v) => stableKey(v)));
|
|
1233
|
+
const $insert = [];
|
|
1234
|
+
const $remove = [];
|
|
1235
|
+
const seenInsert = /* @__PURE__ */ new Set();
|
|
1236
|
+
const seenRemove = /* @__PURE__ */ new Set();
|
|
1237
|
+
for (const v of after) {
|
|
1238
|
+
const k = stableKey(v);
|
|
1239
|
+
if (!beforeSet.has(k) && !seenInsert.has(k)) {
|
|
1240
|
+
$insert.push(v);
|
|
1241
|
+
seenInsert.add(k);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
for (const v of before) {
|
|
1245
|
+
const k = stableKey(v);
|
|
1246
|
+
if (!afterSet.has(k) && !seenRemove.has(k)) {
|
|
1247
|
+
$remove.push(v);
|
|
1248
|
+
seenRemove.add(k);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
return arrayOps({
|
|
1252
|
+
$insert,
|
|
1253
|
+
$remove
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Assembles a `TArrayPatch` from named op-arrays, dropping empty ones. Returns
|
|
1258
|
+
* `undefined` when no op carries any item, so the caller skips the field rather
|
|
1259
|
+
* than emitting a malformed empty `{}`.
|
|
1260
|
+
*/
|
|
1261
|
+
function arrayOps(ops) {
|
|
1262
|
+
const result = {};
|
|
1263
|
+
for (const k in ops) if (ops[k].length > 0) result[k] = ops[k];
|
|
1264
|
+
return Object.keys(result).length > 0 ? result : void 0;
|
|
1265
|
+
}
|
|
1266
|
+
/** Reads `@expect.array.key` props from an array item's object type. */
|
|
1267
|
+
function getArrayKeyProps(itemType) {
|
|
1268
|
+
if (itemType.type.kind !== "object") return [];
|
|
1269
|
+
const props = itemType.type.props;
|
|
1270
|
+
const keys = [];
|
|
1271
|
+
for (const [name, prop] of props.entries()) if (getFieldMeta(prop, "expect.array.key") !== void 0) keys.push(name);
|
|
1272
|
+
return keys;
|
|
1273
|
+
}
|
|
1274
|
+
/** True when the array's item type is a primitive (designType, kind === ''). */
|
|
1275
|
+
function isPrimitiveItem(field) {
|
|
1276
|
+
return field.itemType.type.kind === "";
|
|
1277
|
+
}
|
|
1278
|
+
/** Finds the form's `@db.column.version` column name, if any (top-level only). */
|
|
1279
|
+
function findVersionColumn(def) {
|
|
1280
|
+
for (const [path, prop] of def.flatMap.entries()) {
|
|
1281
|
+
if (!path || path.includes(".")) continue;
|
|
1282
|
+
if (getFieldMeta(prop, "db.column.version") !== void 0) return path;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
/** True when any element lacks ALL of its key values (un-keyable identity). */
|
|
1286
|
+
function hasKeylessItem(arr, keyProps) {
|
|
1287
|
+
for (const el of arr) {
|
|
1288
|
+
if (!isPlainObject(el)) return true;
|
|
1289
|
+
let hasAnyKey = false;
|
|
1290
|
+
for (const k of keyProps) {
|
|
1291
|
+
const v = el[k];
|
|
1292
|
+
if (v !== void 0 && v !== null) {
|
|
1293
|
+
hasAnyKey = true;
|
|
1294
|
+
break;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
if (!hasAnyKey) return true;
|
|
1298
|
+
}
|
|
1299
|
+
return false;
|
|
1300
|
+
}
|
|
1301
|
+
/** Composite key string for a keyed-array element. */
|
|
1302
|
+
function keyOf(el, keyProps) {
|
|
1303
|
+
if (keyProps.length === 1) return stableKey(el[keyProps[0]]);
|
|
1304
|
+
return keyProps.map((k) => stableKey(el[k])).join(" ");
|
|
1305
|
+
}
|
|
1306
|
+
/** Picks only the key fields from an element (for $remove). */
|
|
1307
|
+
function pickKeys(el, keyProps) {
|
|
1308
|
+
const out = {};
|
|
1309
|
+
for (const k of keyProps) out[k] = el[k];
|
|
1310
|
+
return out;
|
|
1311
|
+
}
|
|
1312
|
+
/** Writes a value into a (possibly dotted) leaf path on a local patch object. */
|
|
1313
|
+
function setPatchLeaf(patch, path, value) {
|
|
1314
|
+
if (!path.includes(".")) {
|
|
1315
|
+
patch[path] = value;
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
const keys = path.split(".");
|
|
1319
|
+
const last = keys.pop();
|
|
1320
|
+
let cur = patch;
|
|
1321
|
+
for (const k of keys) {
|
|
1322
|
+
let next = cur[k];
|
|
1323
|
+
if (next === void 0 || next === null || typeof next !== "object") {
|
|
1324
|
+
next = {};
|
|
1325
|
+
cur[k] = next;
|
|
1326
|
+
}
|
|
1327
|
+
cur = next;
|
|
1328
|
+
}
|
|
1329
|
+
cur[last] = value;
|
|
1330
|
+
}
|
|
1331
|
+
function isPlainObject(v) {
|
|
1332
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Stable string key for primitives / values (used for set membership).
|
|
1336
|
+
*
|
|
1337
|
+
* Key equality is TYPE-STRICT: a number `1` and a string `'1'` produce
|
|
1338
|
+
* distinct keys, so a keyed-array item whose key changes JS representation
|
|
1339
|
+
* between baseline and current is treated as a remove + insert. Callers that
|
|
1340
|
+
* round-trip keys with loose typing should normalise the key type first.
|
|
1341
|
+
*/
|
|
1342
|
+
function stableKey(v) {
|
|
1343
|
+
if (typeof v === "string") return `s:${v}`;
|
|
1344
|
+
if (typeof v === "number" || typeof v === "boolean") return `p:${String(v)}`;
|
|
1345
|
+
if (v === null) return "null";
|
|
1346
|
+
if (v === void 0) return "undef";
|
|
1347
|
+
return `j:${JSON.stringify(v)}`;
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Structural deep equality (order-sensitive for arrays). `NaN` equals `NaN`
|
|
1351
|
+
* (revert-aware for NaN scalars) while `0` / `-0` stay equal (matches DB
|
|
1352
|
+
* intent — `===` treats them equal, only NaN is special-cased).
|
|
1353
|
+
*/
|
|
1354
|
+
function deepEqual(a, b) {
|
|
1355
|
+
if (a === b) return true;
|
|
1356
|
+
if (typeof a === "number" && typeof b === "number") return Number.isNaN(a) && Number.isNaN(b);
|
|
1357
|
+
if (a === null || b === null || a === void 0 || b === void 0) return false;
|
|
1358
|
+
if (typeof a !== "object" || typeof b !== "object") return false;
|
|
1359
|
+
const aIsArr = Array.isArray(a);
|
|
1360
|
+
const bIsArr = Array.isArray(b);
|
|
1361
|
+
if (aIsArr !== bIsArr) return false;
|
|
1362
|
+
if (aIsArr && bIsArr) {
|
|
1363
|
+
if (a.length !== b.length) return false;
|
|
1364
|
+
for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
|
|
1365
|
+
return true;
|
|
1366
|
+
}
|
|
1367
|
+
const ao = a;
|
|
1368
|
+
const bo = b;
|
|
1369
|
+
const aKeys = Object.keys(ao);
|
|
1370
|
+
const bKeys = Object.keys(bo);
|
|
1371
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
1372
|
+
for (const k of aKeys) {
|
|
1373
|
+
if (!Object.prototype.hasOwnProperty.call(bo, k)) return false;
|
|
1374
|
+
if (!deepEqual(ao[k], bo[k])) return false;
|
|
1375
|
+
}
|
|
1376
|
+
return true;
|
|
1377
|
+
}
|
|
1378
|
+
//#endregion
|
|
950
1379
|
//#region src/form/error-utils.ts
|
|
951
1380
|
/**
|
|
952
1381
|
* Framework-agnostic helpers for working with form-error maps keyed by
|
|
@@ -1553,4 +1982,4 @@ function getColumn(def, path) {
|
|
|
1553
1982
|
return def.columns.find((c) => c.path === path);
|
|
1554
1983
|
}
|
|
1555
1984
|
//#endregion
|
|
1556
|
-
export { DB_AMOUNT_CURRENCY, DB_AMOUNT_CURRENCY_REF, DB_COLUMN_PRECISION, DB_HTTP_PATH, DB_REL_FK, DB_UNIT, DB_UNIT_REF, DEFAULT_COL_SPAN, DEFAULT_ROW_SPAN, EXPECT_MAX_LENGTH, META_DEFAULT, META_DESCRIPTION, META_ID, META_LABEL, META_READONLY, META_REQUIRED, META_SENSITIVE, StaticFieldResolver, UI_DICT_ATTR, UI_DICT_DESCR, UI_DICT_FILTERABLE, UI_DICT_LABEL, UI_DICT_SEARCHABLE, UI_DICT_SORTABLE, UI_FORM_ACTION, UI_FORM_ATTR, UI_FORM_AUTOCOMPLETE, UI_FORM_CLASSES, UI_FORM_COMPONENT, UI_FORM_DISABLED, UI_FORM_FN_ATTR, UI_FORM_FN_CLASSES, UI_FORM_FN_DESCRIPTION, UI_FORM_FN_DISABLED, UI_FORM_FN_HIDDEN, UI_FORM_FN_HINT, UI_FORM_FN_LABEL, UI_FORM_FN_OPTIONS, UI_FORM_FN_PLACEHOLDER, UI_FORM_FN_PREFIX, UI_FORM_FN_READONLY, UI_FORM_FN_STYLES, UI_FORM_FN_SUBMIT_DISABLED, UI_FORM_FN_SUBMIT_TEXT, UI_FORM_FN_TITLE, UI_FORM_FN_VALUE, UI_FORM_GRID_COL_SPAN, UI_FORM_GRID_ROW_SPAN, UI_FORM_HIDDEN, UI_FORM_HINT, UI_FORM_LABEL_SINGULAR, UI_FORM_OPTIONS, UI_FORM_ORDER, UI_FORM_PLACEHOLDER, UI_FORM_PREFIX, UI_FORM_PREFIX_ICON, UI_FORM_PREFIX_REF, UI_FORM_STYLES, UI_FORM_SUBMIT_TEXT, UI_FORM_SUFFIX, UI_FORM_SUFFIX_ICON, UI_FORM_SUFFIX_REF, UI_FORM_TYPE, UI_FORM_VALIDATE, UI_TABLE_ATTR, UI_TABLE_CLASSES, UI_TABLE_COMPONENT, UI_TABLE_FN_ATTR, UI_TABLE_FN_CLASSES, UI_TABLE_FN_PREFIX, UI_TABLE_FN_STYLES, UI_TABLE_HIDDEN, UI_TABLE_ORDER, UI_TABLE_SELECT_WITH, UI_TABLE_STYLES, UI_TABLE_TYPE, UI_TABLE_WIDTH, UI_TYPE, ValueHelpClient, WF_ACTION_WITH_DATA, asArray, buildDescendantErrorCounts, buildGridClasses, buildUnionVariants, createFieldValidator, createFormData, createFormDef, createFormValueResolver, createTableDef, defaultResolver, detectUnionVariant, enforceScale, extractLiteralOptions, extractMeasurement, extractValueHelp, formatDecimalForDisplay, getByPath, getColumn, getCurrencyDecimals, getCurrencyDisplayParts, getDecimalSeparator, getDeclaredFormActions, getDefaultClientFactory, getDefaultValidatorPlugins, getFieldMeta, getFilterableColumns, getFormValidator, getMetaEntry, getResolver, getSortableColumns, getThousandsSeparator, getVisibleColumns, groupInteger, hasComputedAnnotations, isArrayField, isObjectField, isPureLiteralUnion, isTupleField, isUnionField, iteratePathAncestors, joinDecimalString, mergeErrorMaps, optKey, optLabel, parseColSpan, parseDecimalInput, parseRowSpan, parseStaticAttrs, parseStaticOptions, resetDefaultClientFactory, resetMetaCache, resetValueHelpCache, resolveAttrs, resolveFieldProp, resolveFormProp, resolveGridSpec, resolveOptions, resolveSingularLabel, resolveStatic, resolveValueHelp, setByPath, setDefaultClientFactory, setDefaultValidatorPlugins, setResolver, splitDecimalString, str, valueHelpDictPaths };
|
|
1985
|
+
export { DB_AMOUNT_CURRENCY, DB_AMOUNT_CURRENCY_REF, DB_COLUMN_PRECISION, DB_HTTP_PATH, DB_REL_FK, DB_UNIT, DB_UNIT_REF, DEFAULT_COL_SPAN, DEFAULT_ROW_SPAN, EXPECT_MAX_LENGTH, META_DEFAULT, META_DESCRIPTION, META_ID, META_LABEL, META_READONLY, META_REQUIRED, META_SENSITIVE, StaticFieldResolver, UI_DICT_ATTR, UI_DICT_DESCR, UI_DICT_FILTERABLE, UI_DICT_LABEL, UI_DICT_SEARCHABLE, UI_DICT_SORTABLE, UI_FORM_ACTION, UI_FORM_ATTR, UI_FORM_AUTOCOMPLETE, UI_FORM_CLASSES, UI_FORM_COMPONENT, UI_FORM_DISABLED, UI_FORM_FN_ATTR, UI_FORM_FN_CLASSES, UI_FORM_FN_DESCRIPTION, UI_FORM_FN_DISABLED, UI_FORM_FN_HIDDEN, UI_FORM_FN_HINT, UI_FORM_FN_LABEL, UI_FORM_FN_OPTIONS, UI_FORM_FN_PLACEHOLDER, UI_FORM_FN_PREFIX, UI_FORM_FN_READONLY, UI_FORM_FN_STYLES, UI_FORM_FN_SUBMIT_DISABLED, UI_FORM_FN_SUBMIT_TEXT, UI_FORM_FN_TITLE, UI_FORM_FN_VALUE, UI_FORM_GRID_COL_SPAN, UI_FORM_GRID_ROW_SPAN, UI_FORM_HIDDEN, UI_FORM_HINT, UI_FORM_LABEL_SINGULAR, UI_FORM_OPTIONS, UI_FORM_ORDER, UI_FORM_PLACEHOLDER, UI_FORM_PREFIX, UI_FORM_PREFIX_ICON, UI_FORM_PREFIX_REF, UI_FORM_STYLES, UI_FORM_SUBMIT_TEXT, UI_FORM_SUFFIX, UI_FORM_SUFFIX_ICON, UI_FORM_SUFFIX_REF, UI_FORM_TYPE, UI_FORM_VALIDATE, UI_TABLE_ATTR, UI_TABLE_CLASSES, UI_TABLE_COMPONENT, UI_TABLE_FN_ATTR, UI_TABLE_FN_CLASSES, UI_TABLE_FN_PREFIX, UI_TABLE_FN_STYLES, UI_TABLE_HIDDEN, UI_TABLE_ORDER, UI_TABLE_SELECT_WITH, UI_TABLE_STYLES, UI_TABLE_TYPE, UI_TABLE_WIDTH, UI_TYPE, ValueHelpClient, WF_ACTION_WITH_DATA, asArray, buildDescendantErrorCounts, buildFormDiff, buildGridClasses, buildUnionVariants, createFieldValidator, createFormData, createFormDef, createFormValueResolver, createTableDef, defaultResolver, detectUnionVariant, enforceScale, extractLiteralOptions, extractMeasurement, extractValueHelp, formatDecimalForDisplay, getByPath, getColumn, getCurrencyDecimals, getCurrencyDisplayParts, getDecimalSeparator, getDeclaredFormActions, getDefaultClientFactory, getDefaultValidatorPlugins, getFieldMeta, getFilterableColumns, getFormValidator, getMetaEntry, getResolver, getSortableColumns, getThousandsSeparator, getVisibleColumns, groupInteger, hasComputedAnnotations, isArrayField, isObjectField, isPureLiteralUnion, isTupleField, isUnionField, iteratePathAncestors, joinDecimalString, mergeErrorMaps, optKey, optLabel, parseColSpan, parseDecimalInput, parseRowSpan, parseStaticAttrs, parseStaticOptions, resetDefaultClientFactory, resetMetaCache, resetValueHelpCache, resolveAttrs, resolveFieldProp, resolveFormProp, resolveGridSpec, resolveOptions, resolveSingularLabel, resolveStatic, resolveValueHelp, setByPath, setDefaultClientFactory, setDefaultValidatorPlugins, setResolver, splitDecimalString, str, valueHelpDictPaths };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atscript/ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.103",
|
|
4
4
|
"description": "Framework-agnostic runtime for form and table definitions from atscript annotated types",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"annotations",
|
|
@@ -47,18 +47,18 @@
|
|
|
47
47
|
"access": "public"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@atscript/db-client": "^0.1.
|
|
50
|
+
"@atscript/db-client": "^0.1.107"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@atscript/core": "^0.1.
|
|
54
|
-
"@atscript/db": "^0.1.
|
|
55
|
-
"@atscript/typescript": "^0.1.
|
|
56
|
-
"unplugin-atscript": "^0.1.
|
|
53
|
+
"@atscript/core": "^0.1.77",
|
|
54
|
+
"@atscript/db": "^0.1.107",
|
|
55
|
+
"@atscript/typescript": "^0.1.77",
|
|
56
|
+
"unplugin-atscript": "^0.1.77",
|
|
57
57
|
"vitest": "npm:@voidzero-dev/vite-plus-test@0.1.14"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"@atscript/core": "^0.1.
|
|
61
|
-
"@atscript/typescript": "^0.1.
|
|
60
|
+
"@atscript/core": "^0.1.77",
|
|
61
|
+
"@atscript/typescript": "^0.1.77"
|
|
62
62
|
},
|
|
63
63
|
"scripts": {
|
|
64
64
|
"build": "vp pack",
|