Skip to content

fix(modifiers): read a stored field with no value as null at the end of the read - #113

Merged
Upd4ting merged 4 commits into
mainfrom
fix/null-at-end-of-read
Nov 11, 2026
Merged

Upd4ting merged 4 commits into
mainfrom
fix/null-at-end-of-read

Conversation

@Upd4ting

@Upd4ting Upd4ting commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Why

#111 made a @Localized() field read with no locale answer null, which DMS's files/route-answers test needs. It produced that null inside the modifier chain, and two problems were accepted for 0.3.1 (listed on #111):

  1. A two-way modifier stacked after @Localized() received null in unlock. A custom unlock, or toJSON for the row, could throw.
  2. EncryptionModifier.unlock's published parameter widened to LockedType | null.

This PR produces the null at the end of the read instead, so no modifier ever receives it.

Summary

 ModifiersDynamicMetadata.get(field)
   stored = row._internal.data[field]
   value = stored
   for each two-way modifier
-    if value !== undefined
+    if value is not undefined or null
       value = modifier.unlock(value, ...)
-  return value
+  return value === undefined && stored !== undefined ? null : value

 ModifiersDynamicMetadata.set / testValue
-  unlock the previous or stored value, whatever it is
+  unlock it only when it is not undefined or null

 LocalizationModifier.unlock(record, key)
-  no key and no fallback value -> null
+  value under key, else the fallback value (as before #111)

 LocalizationModifier.lock(record, value, key)
-  drops "" under a locale
+  drops "" and null under a locale

 EncryptionModifier.unlock
-  (locked: LockedType | null)
+  ([ciphertext, iv, authTag]: LockedType)   (as before #111)
  • A field the row stores reads null when its modifiers find no value, or when it stores null.
  • A field the row does not store, or cannot read yet, still reads undefined.
  • No modifier's unlock receives undefined or null. That covers the read, set's previous-value unlock and testValue. As a side effect, the first assignment to a stacked field such as @Localized() @Encrypted() on a row that stores nothing no longer throws. It threw on main too.
  • null under a locale is stored as no text, as "" already was. A row read with no text answers null, so saving that answer again (GET, then PUT, or the lock() helper) stores nothing, and the fallbackLocale still answers. The round-1 review found that this write-back blocked the fallback once reads answered null.
  • docs/3.table-modifiers.md and the shipped skill say all of this.

Behaviour change, minor release (0.4.0)

Read 0.3.2 This PR
localize("de") on a field with no de text and no fallbackLocale undefined (left out of JSON) null
Base64 docs example, stored value not a string undefined null
@Encrypted() field stored as null null null (no longer decrypted)
No-key read of an unset @Localized() field (#111) null null
Field the row does not store undefined undefined
null assigned under a locale stored { de: null } stored as no text

Evidence

Each new test fails on main (3b3ff72, or on 74a193b for the write-back tests) and passes on this branch.

Finding 2. The test types its override from Parameters<EncryptionModifier["unlock"]>[0] and reads stored[1]:

before: src/tests/modifiers/encryption.test.ts(162,28): error TS18047: 'stored' is possibly 'null'.
after:  ✔ unlocks a stored value typed from unlock's parameter, which excludes null

Finding 1, and the localize("de") change (src/tests/end_of_read_null.test.ts):

before:
  Modified fields - a read that finds no value
    1) never passes null to a two-way modifier stacked after @Localized()
       TypeError: object null is not iterable
         at WrapModifier.unlock
         at ModifiersDynamicMetadata.get
    2) reads null for a locale with no value and no fallback
       AssertionError: expected undefined to equal null
    ✔ reads undefined for a field the row does not store
after:
    ✔ never passes null to a two-way modifier stacked after @Localized()
    ✔ reads null for a locale with no value and no fallback
    ✔ reads undefined for a field the row does not store

Write-back of a null read (round-1 review finding). Before is 74a193b, the first commit of this branch:

before:
    written back
      1) keeps the fallback after a read answer is saved again
         AssertionError: expected null to equal 'Salut'
      2) stores no text when lock writes back a read with no text
         AssertionError: expected { fr: 'Salut', en: null } to deeply equal { fr: 'Salut' }
  Localized fields - plain data with values by locale
    3) stores a null value as no text under the locale it is read with
       AssertionError: expected { en: null } to deeply equal {}
after:
      ✔ keeps the fallback after a read answer is saved again
      ✔ stores no text when lock writes back a read with no text
    ✔ stores a null value as no text under the locale it is read with

Stored null on an @Encrypted() field (round-1 review finding; 0.3.2 handled it inside unlock):

before: 1) reads a stored null as null, without decrypting it
           TypeError: object null is not iterable (cannot read property Symbol(Symbol.iterator))
after:  ✔ reads a stored null as null, without decrypting it

Stacked field and testValue (round-2 review findings; the assignment also throws on main):

before: 1) takes a new value on a stacked field that stores nothing or null
           TypeError: Cannot convert undefined or null to object
        2) reads a stored null as null, without decrypting it   (testValue)
           TypeError: object null is not iterable (cannot read property Symbol(Symbol.iterator))
after:  ✔ takes a new value on a stacked field that stores nothing or null
        ✔ reads a stored null as null, without decrypting it

#111's own test still passes: ✔ answers null for a field the request did not send.

DMS files/route-answers against the packed head (ca69b81), linked into a fresh dms main clone (ffcee020) through a pnpm.overrides file: tarball. A load tracer confirmed the patched dist/modifiers/common.js was the only copy loaded:

DMS_PREBUILT=1 DMS_TEST_DIR=dist/test/integration pnpm exec ajs module test . \
  -f dist/test/integration/setup.test.js -f dist/test/integration/files/route-answers.test.js

    ✔ answers the saved row from new and from every edit
    ✔ answers the deleted-row count from delete, and 404 from an edit of a missing row
    ✔ answers the refusal of a forbidden key
  3 passing

Other checks:

  • pnpm run checks passes all 11 checks.
  • The docs check (tooling-configs v0.0.9) reports 0 failures.
  • antelopejs-verify journey --plan passes all 73 journeys.

Merge Danger

Door: two-way. No stored schema changes. Rows written by 0.3.x read the same, except where the table above says otherwise.

Blast Radius: consumers.

  • A consumer that tests a localized read with === undefined to detect a missing locale now gets null. Hence the minor release.
  • A JSON answer that used to leave such a field out now carries "field": null.

Attention

  • Still open. On a @Localized() @Encrypted() field, a null read posted back under a locale is encrypted before the localization modifier sees it. It is stored as encrypted null under that locale, and it still blocks the fallbackLocale. No AntelopeJS repo stacks the two outside tests and verify playgrounds. Fixing it would need a write-side rule for null in every two-way modifier, so it is left for a follow-up.
  • No version bump. The release commit sets 0.4.0.

@Upd4ting Upd4ting closed this Nov 11, 2026
@Upd4ting Upd4ting reopened this Nov 11, 2026
@Upd4ting
Upd4ting merged commit 0fa3551 into main Nov 11, 2026
8 checks passed
@Upd4ting
Upd4ting deleted the fix/null-at-end-of-read branch November 11, 2026 23:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant