Repository navigation
mapMergePatch: leave unchanged null-valued keys out of the patch - #36
Conversation
A map field's key whose value is null (nbformat allows null in Jupyter metadata) was re-sent as null by every update of that map, even when the key didn't change: the second loop skipped only keys of the old map with a non-null value. Peers apply a null key as a delete, so the first edit of the map deleted the key everywhere (sagemathinc/cocalc-ai#1388). Skip every key the old map already has; the first loop handles them. Removed keys are still sent as null, and a null key that gets a value is sent with it. Both document backends share mapMergePatch. The change is wire-compatible: patches only get smaller.
williamstein
left a comment
There was a problem hiding this comment.
Reviewed at 3e47956. The fix looks right to me.
Correctness, for every combination of a key's old and new value. The first loop handles every key obj1 has: it sends nothing when the value is unchanged (deepEqual, so nested maps are compared whole) and sends null when the key was removed. So the second loop only needs keys new in obj2, and hasOwnProperty is the right test. The old obj1[key] != null test let a key whose old value was null through, and re-sent it as null.
I checked this with a scratch matrix test, not part of this PR. For one key k of a map field meta: {other: 1, k: <old>}, it runs set({id: 1, meta: {touch: 1, k: <given>}}) and compares the local document with a peer that applies makePatch. That is 4 old values (absent, null, 1, a nested map {x: 1, y: null}) times 5 givens (not given, null, 2, the same nested map, another nested map), on both backends: 40 cases.
- On this head, all 40 agree between the local document and the peer, and
otheris kept in every case. - On main, the local document and the peer disagree in exactly 2 cases: old
null,knot given, on both backends. That is #1388's case: the local document keepsk: nulland the peer deletes it. - The behavior that stays as it was, as intended:
- a set of
k: nulldeletesklocally and on the peer, whatever the old value, a null included; - setting a null key to a value sends the value;
- a nested map is replaced whole, its inner nulls kept;
- an absent key set to
nullstays absent, and nothing is sent for it.
- a set of
The suite passes here too: 250 passed, 1 skipped.
Whether #35's opt-in is still needed. Not for #1388's reported path. With this fix and an edit path that sets only the keys it changes (the cocalc-ai side), an imported null key survives other edits. It is still needed wherever an application must write a null value rather than keep one:
- a map key set to
nullon purpose is a delete. A"key": nullthat a user types into a notebook's metadata editor would be deleted rather than stored. I haven't run that path; it follows from null-as-delete; - a record's top-level fields: inserts drop null fields, and a patch sends a removed field as
null. So a top-level null value can't round-trip through a patch at all, and this PR doesn't touch that path.
So I'd keep #35 open: null as a value, with an explicit unset, opt-in per document type. It is just no longer what #1388 depends on.
Optional: the matrix above could go into db-document.test.ts as a table-driven test, so every (old, new) pair stays covered on both backends.
-- @sagebrush-board
|
Follow-up, for @cocalc-dev-lite2's questions. Verdict: CLEAR. Both codecs. The matrix in my review above ran on both backends, immutable.js and Immer, through each one's own Can any caller rely on an unchanged null key being re-sent? No. The patch differs from main's only when
Mixed versions:
Scope, for the record. This fixes null-valued keys inside map-valued fields. A record's top-level null fields are a separate path: inserts drop them ( -- @sagebrush-board |
patchflow 0.16.1 (sagemathinc/patchflow#36) makes a map patch send only the keys that changed, so a peer no longer deletes null-valued keys the edit did not touch. With this PR's Jupyter change, null-valued metadata keys now survive for peers too (#1388). patchflow is already in minimumReleaseAgeExclude as a CoCalc-owned package.
* jupyter: metadata edits keep null-valued metadata keys (#1388) nbformat allows null values in cell and notebook metadata, and the import keeps them. set_cell_metadata({merge: true}) and set_global_metadata set the whole merged map, so the document's shallow merge, where null means delete, deleted every null-valued key: toggling "hide input", nbgrader edits or the output limit lost them locally, for collaborators and in the saved .ipynb. Set only the given keys: the document merges a map field shallowly, so the other keys are kept as they are. Peers also keep them once patchflow leaves unchanged null keys out of the patch (sagemathinc/patchflow#36). test/metadata-nulls.test.ts drives the real actions on a patchflow document imported from a notebook with null metadata, locally and on a peer applying the edit's patch. * deps: patchflow 0.16.1, which no longer re-sends unchanged null map keys patchflow 0.16.1 (sagemathinc/patchflow#36) makes a map patch send only the keys that changed, so a peer no longer deletes null-valued keys the edit did not touch. With this PR's Jupyter change, null-valued metadata keys now survive for peers too (#1388). patchflow is already in minimumReleaseAgeExclude as a CoCalc-owned package. --------- Co-authored-by: ops3 <ops3@cocalc.ai>
Fixes the patchflow side of sagemathinc/cocalc-ai#1388: an update of a map-valued field deleted that map's null-valued keys on every peer.
The bug
mapMergePatch(obj1, obj2)builds the change for a map-valued field. Its second loop, meant for keys added inobj2, skipped only keys whose old value is non-null:So a key whose value is
nullin both the old and the new map went into every update's patch askey: null. Peers apply a null map key as a delete (mergeSet/mergeObject), so the first edit of any other key of the map deleted it everywhere.In cocalc-ai, Jupyter cell and notebook
metadatacan hold such keys: nbformat allows null, and the import keeps them. Any metadata edit, such as toggling "hide input", then lost them. Measured on 0.16.0, withmetadata: {keep_me: null, other: 1}and an edit of onlyjupyter:The fix
The second loop skips every key
obj1already has, since the first loop handles them: unchanged keys are left out, and changed or removed keys are sent. Both document backends (immutable.js and Immer) sharemapMergePatch.Unchanged:
null;Wire-compatible: patches only get smaller, and old peers apply them as before.
Tests
db-document.test.ts, run on both backends:Both tests fail on main.
pnpm test(250 passed),pnpm lintandpnpm build: pass.Note
cocalc-ai also needs its Jupyter edit paths to set only the changed metadata keys. Today they set the whole map, null keys included, and the local merge deletes them. That part is a separate cocalc-ai PR, which picks up this fix in a patchflow release.