From 74a193b0e5c5a689cd08e3d53df86857de5acc99 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Wed, 11 Nov 2026 20:34:40 +0000 Subject: [PATCH 1/4] fix(modifiers): read a stored field with no value as null at the end of the read --- docs/3.table-modifiers.md | 6 +- skills/database-decorators-interface/SKILL.md | 1 + src/modifiers/common.ts | 10 ++- src/modifiers/encryption.ts | 10 +-- src/modifiers/localization.ts | 12 ++-- src/tests/end_of_read_null.test.ts | 72 +++++++++++++++++++ src/tests/modifiers/encryption.test.ts | 21 ++++++ src/tests/modifiers/localization.test.ts | 2 +- 8 files changed, 112 insertions(+), 22 deletions(-) create mode 100644 src/tests/end_of_read_null.test.ts diff --git a/docs/3.table-modifiers.md b/docs/3.table-modifiers.md index c46df6f..d8fcf73 100644 --- a/docs/3.table-modifiers.md +++ b/docs/3.table-modifiers.md @@ -198,6 +198,8 @@ const german = product.localize("de"); console.log(german.name); // "Premium Headphones" (fallback) ``` +A field without a `fallbackLocale` reads `null` in a locale that has no text. A field the row does not store at all reads `undefined`. + #### Wildcard key The wildcard key `"*"` reads or writes all translations at once. List the localized fields with it, and `localize("*", fields)` types each listed field as its values by locale, a `LocalizedRecords`: a field declared as `string` reads as a `Record`. Calling `localize()` on that view with one locale gives back the row type. The view is not the row type, so pass the row itself to `insert()` or `update()`. Write all translations with `Object.assign`, which calls the field's setter. `undefined` or `null` clears every translation. Any other value that is not a record, such as a single text, throws a `TypeError` and leaves the stored translations as they were: @@ -422,11 +424,11 @@ const Base64Encoded = MakePropertyDecorator( ); ``` -Base64 is an encoding, not encryption. The example declares its `LockedType` as `unknown`, so `unlockrequest()` can return the stored value's proxy as it is. Database expressions therefore see the encoded value, and only instance reads decode it. `unlock` reads a stored value that is not a string as `undefined`. +Base64 is an encoding, not encryption. The example declares its `LockedType` as `unknown`, so `unlockrequest()` can return the stored value's proxy as it is. Database expressions therefore see the encoded value, and only instance reads decode it. `unlock` reads a stored value that is not a string as `undefined`, so the field reads `null`: a field the row stores reads `null` when its modifiers find no value. ### `ContainerModifier` -A modifier that stores several values under keys, as `LocalizationModifier` does, extends `ContainerModifier`. Its `lock`, `unlock`, and `unlockrequest` take the key as their argument, a `string` or `undefined`. `lock` and `unlock` treat the wildcard key `"*"` as the whole record, and `lock` throws a `TypeError` for a value under `"*"` that is not a record. `undefined` is no key: `lock` stores nothing under it, and `unlock` and `unlockrequest` find no value, so a `LocalizationModifier` answers its `fallbackLocale`, or `null` when that locale has no value either: +A modifier that stores several values under keys, as `LocalizationModifier` does, extends `ContainerModifier`. Its `lock`, `unlock`, and `unlockrequest` take the key as their argument, a `string` or `undefined`. `lock` and `unlock` treat the wildcard key `"*"` as the whole record, and `lock` throws a `TypeError` for a value under `"*"` that is not a record. `undefined` is no key: `lock` stores nothing under it, and `unlock` and `unlockrequest` find no value, so a `LocalizationModifier` answers its `fallbackLocale`, and the field reads `null` when that locale has no value either. The modifier chain passes no `null` to a modifier stacked after it: ```typescript import { ContainerModifier } from "@antelopejs/interface-database-decorators"; diff --git a/skills/database-decorators-interface/SKILL.md b/skills/database-decorators-interface/SKILL.md index f854be6..16dd462 100644 --- a/skills/database-decorators-interface/SKILL.md +++ b/skills/database-decorators-interface/SKILL.md @@ -135,6 +135,7 @@ class Chef extends Table.with( - Under `localize("*")` a localized field takes the record of texts by locale, and `undefined` or `null` clears every locale. A single text throws a `TypeError`, whether it is assigned or held from `fromPlainData` when `localize("*")` runs. - A localized value `""` is stored as no value: reads and queries in that locale get the `fallbackLocale` value. - A `ContainerModifier` key of `undefined` is no key: writes store nothing under it, and reads and queries get no value. A localized field reads the `fallbackLocale` value, or `null` when there is none. +- A modified field the row stores reads `null` when its modifiers find no value, such as `localize("de")` with no German text and no `fallbackLocale`. A field the row does not store reads `undefined`. A custom `unlock` never receives `null` from the modifier above it. - `@Relation({ to: () => Target })` is declarative metadata only: no foreign-key enforcement, consumed by introspection tooling via `RelationStaticMetadata`. - `Model.validate` (and `{ validate: true }` on insert/update) only checks fields whose `@Field` type is an io-ts-style codec with `.decode`; plain string field tokens are skipped. Update validates with diff --git a/src/modifiers/common.ts b/src/modifiers/common.ts index 5dbbf0e..ac5d3f3 100644 --- a/src/modifiers/common.ts +++ b/src/modifiers/common.ts @@ -136,6 +136,11 @@ export class ModifiersDynamicMetadata { ); } + /** + * Reads a field through its modifiers. A field the row stores reads `null` + * when its modifiers find no value in it, as under a locale with no text. + * A field the row does not store, or cannot read yet, reads `undefined`. + */ get(field: PropertyKey, internal: InternalRecord) { if (Object.hasOwn(this.floating, field)) { return floatingByKey(this)[field]; @@ -144,7 +149,8 @@ export class ModifiersDynamicMetadata { return; } const list = fieldsByKey(this.staticMeta)[field]; - let value = dataByKey(internal)[field]; + const stored = dataByKey(internal)[field]; + let value = stored; for (const { id, modifier, metaRef } of list) { if (isTwoWay(modifier) && value !== undefined) { if (unlockArgsOf(this, field)[id]) { @@ -158,7 +164,7 @@ export class ModifiersDynamicMetadata { } } } - return value; + return value === undefined && stored !== undefined ? null : value; } canSet(field: PropertyKey) { diff --git a/src/modifiers/encryption.ts b/src/modifiers/encryption.ts index b4f9725..b6dd516 100644 --- a/src/modifiers/encryption.ts +++ b/src/modifiers/encryption.ts @@ -152,15 +152,7 @@ export class EncryptionModifier extends TwoWayModifier< readonly autounlock = true; - /** - * Decrypts a stored value. `null` is no value and stays `null`, as a - * localized field read with no locale gives it. - */ - public override unlock(locked: LockedType | null) { - if (locked === null) { - return null; - } - const [locked_value, iv_str, authTag] = locked; + public override unlock([locked_value, iv_str, authTag]: LockedType) { const decipher = createDecipheriv( this.options.algorithm || "aes-256-gcm", this.options.secretKey, diff --git a/src/modifiers/localization.ts b/src/modifiers/localization.ts index d19a1d1..de67e08 100644 --- a/src/modifiers/localization.ts +++ b/src/modifiers/localization.ts @@ -102,10 +102,7 @@ export class LocalizationModifier extends ContainerModifier< return withoutEmptyTexts(super.lock(locked_value, value, key)); } - /** - * The value under `key`, else under the fallback locale. With no `key`, a - * field without a fallback value reads `null`, as an unset field does. - */ + /** The value under `key`, else under the fallback locale. */ public override unlock( // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the container modifier leaves the values by locale untyped locked_value: Record, @@ -115,10 +112,9 @@ export class LocalizationModifier extends ContainerModifier< if (unlocked !== undefined && unlocked !== EMPTY_TEXT) { return unlocked; } - const fallback = this.options.fallbackLocale - ? super.unlock(locked_value, this.options.fallbackLocale) - : undefined; - return key === undefined ? (fallback ?? null) : fallback; + if (this.options.fallbackLocale) { + return super.unlock(locked_value, this.options.fallbackLocale); + } } /** diff --git a/src/tests/end_of_read_null.test.ts b/src/tests/end_of_read_null.test.ts new file mode 100644 index 0000000..48dbe25 --- /dev/null +++ b/src/tests/end_of_read_null.test.ts @@ -0,0 +1,72 @@ +import { expect } from "chai"; +import { MakePropertyDecorator } from "@antelopejs/interface-core/decorators"; +import { Field, Table } from "../table"; +import { + attachModifier, + fromDatabase, + MixinSymbol, + TwoWayModifier, + unlock, +} from "../modifiers/common"; +import { LocalizationModifier, Localized } from "../modifiers/localization"; + +class WrapModifier extends TwoWayModifier<[text: string]> { + readonly autolock = true; + readonly autounlock = true; + + public override lock(_locked: [text: string] | undefined, value: string) { + return [value] satisfies [text: string]; + } + + public override unlock([text]: [text: string]) { + return text; + } + + [MixinSymbol] = class {}; +} + +const Wrapped = MakePropertyDecorator((target, propertyKey) => { + attachModifier(target.constructor, WrapModifier, propertyKey, {}); +}); + +describe("Modified fields - a read that finds no value", () => { + class Page extends Table.with(LocalizationModifier, WrapModifier) { + @Localized() @Wrapped() @Field("string") declare title?: string; + @Localized() @Field("string") declare summary?: string; + } + + function pageInEnglish() { + const stored = { + _id: "page-1", + _internal: { + meta: {}, + data: { title: { en: ["Hello"] }, summary: { en: "Greeting" } }, + }, + }; + return fromDatabase(stored, Page).localize("en"); + } + + it("never passes null to a two-way modifier stacked after @Localized()", () => { + const page = pageInEnglish(); + unlock(page, LocalizationModifier, undefined, undefined); + + expect(page.title).to.equal(null); + expect(JSON.parse(JSON.stringify(page))).to.deep.include({ title: null }); + }); + + it("reads null for a locale with no value and no fallback", () => { + const page = pageInEnglish(); + const english = { title: page.title, summary: page.summary }; + page.localize("de"); + + expect(english).to.deep.equal({ title: "Hello", summary: "Greeting" }); + expect(page.summary).to.equal(null); + expect(page.title).to.equal(null); + }); + + it("reads undefined for a field the row does not store", () => { + const page = new Page().localize("en"); + + expect(page.summary).to.equal(undefined); + }); +}); diff --git a/src/tests/modifiers/encryption.test.ts b/src/tests/modifiers/encryption.test.ts index f2dd428..97b91e5 100644 --- a/src/tests/modifiers/encryption.test.ts +++ b/src/tests/modifiers/encryption.test.ts @@ -1,6 +1,7 @@ import { expect } from "chai"; import { Field, Table } from "../../table"; import { Encrypted, EncryptionModifier } from "../../modifiers/encryption"; +import { attachModifier } from "../../modifiers/common"; describe("Modifiers - encryption", () => { it("creates encryption modifier", async () => { @@ -153,4 +154,24 @@ describe("Modifiers - encryption", () => { expect(instance.payload).to.deep.equal(original); expect(instance.payload.date).to.be.instanceOf(Date); }); + it("unlocks a stored value typed from unlock's parameter, which excludes null", () => { + class IvLength extends EncryptionModifier { + public override unlock( + stored: Parameters[0], + ) { + return Buffer.from(stored[1], "base64").length; + } + } + class Note extends Table.with(IvLength) { + @Field("string") declare body: number | string; + } + attachModifier(Note, IvLength, "body", { + secretKey: "12345678901234567890123456789012", + }); + + const note = new Note(); + note.body = "hello"; + + expect(note.body).to.equal(16); + }); }); diff --git a/src/tests/modifiers/localization.test.ts b/src/tests/modifiers/localization.test.ts index af9148e..e6b6821 100644 --- a/src/tests/modifiers/localization.test.ts +++ b/src/tests/modifiers/localization.test.ts @@ -244,7 +244,7 @@ describe("Modifiers - localization", () => { page.localize("*").title = undefined; expect(toDatabase(unset)._internal?.data.title).to.deep.equal({}); - expect(page.localize("en").title).to.equal(undefined); + expect(page.localize("en").title).to.equal(null); expect(toDatabase(page)._internal?.data.title).to.deep.equal({}); }); it("maintains data structure integrity", async () => { From ad2c4b0c08cd0bb45bdc0c2f9093d77d6ec935aa Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Wed, 11 Nov 2026 20:41:10 +0000 Subject: [PATCH 2/4] fix(localized): store null under a locale as no text, so a read answer saved again keeps the fallback --- docs/3.table-modifiers.md | 2 +- skills/database-decorators-interface/SKILL.md | 2 +- src/modifiers/localization.ts | 20 +++++---- src/tests/end_of_read_null.test.ts | 41 +++++++++++++++++++ src/tests/localized_plain_data.test.ts | 5 ++- 5 files changed, 58 insertions(+), 12 deletions(-) diff --git a/docs/3.table-modifiers.md b/docs/3.table-modifiers.md index d8fcf73..68431a8 100644 --- a/docs/3.table-modifiers.md +++ b/docs/3.table-modifiers.md @@ -198,7 +198,7 @@ const german = product.localize("de"); console.log(german.name); // "Premium Headphones" (fallback) ``` -A field without a `fallbackLocale` reads `null` in a locale that has no text. A field the row does not store at all reads `undefined`. +A field without a `fallbackLocale` reads `null` in a locale that has no text. A field the row does not store at all reads `undefined`. An empty text `""` or `null` assigned in a locale is stored as no text, so saving a row read with no text stores nothing and the fallback still answers. #### Wildcard key diff --git a/skills/database-decorators-interface/SKILL.md b/skills/database-decorators-interface/SKILL.md index 16dd462..d743e7b 100644 --- a/skills/database-decorators-interface/SKILL.md +++ b/skills/database-decorators-interface/SKILL.md @@ -133,7 +133,7 @@ class Chef extends Table.with( - In plain data, a plain object given for a localized field is its texts by locale: `fromPlainData` and `insert` store every locale, whatever `localize()` call follows, and `update` replaces the stored texts with that record. A single text still waits for a locale. A field with another modifier inside its locales, such as `@Localized() @Encrypted()`, does not read a record this way. - `instance.localize("*", ["motto"])` reads and writes every locale at once, typed `Record` for each listed field (`LocalizedRecords`); without a field list the field types stay unchanged. - Under `localize("*")` a localized field takes the record of texts by locale, and `undefined` or `null` clears every locale. A single text throws a `TypeError`, whether it is assigned or held from `fromPlainData` when `localize("*")` runs. -- A localized value `""` is stored as no value: reads and queries in that locale get the `fallbackLocale` value. +- A localized value `""` or `null` is stored as no value: reads and queries in that locale get the `fallbackLocale` value. A row read with no text answers `null`, and saving that answer again stores nothing. - A `ContainerModifier` key of `undefined` is no key: writes store nothing under it, and reads and queries get no value. A localized field reads the `fallbackLocale` value, or `null` when there is none. - A modified field the row stores reads `null` when its modifiers find no value, such as `localize("de")` with no German text and no `fallbackLocale`. A field the row does not store reads `undefined`. A custom `unlock` never receives `null` from the modifier above it. - `@Relation({ to: () => Target })` is declarative metadata only: no foreign-key enforcement, consumed by introspection tooling via `RelationStaticMetadata`. diff --git a/src/modifiers/localization.ts b/src/modifiers/localization.ts index de67e08..8fc18a5 100644 --- a/src/modifiers/localization.ts +++ b/src/modifiers/localization.ts @@ -39,12 +39,15 @@ export type LocalizedRecords = Omit< localize(locale: string, fields?: ReadonlyArray): This; }; -/** A text left empty in one language counts as no text in that language. */ -const EMPTY_TEXT = ""; +/** + * The values that count as no text in one language: an empty text, and the + * `null` a read with no text answers. + */ +const NO_TEXT = new Set([undefined, null, ""]); -function withoutEmptyTexts(values: Record): Record { +function withoutMissingTexts(values: Record): Record { return Object.fromEntries( - Object.entries(values).filter(([, value]) => value !== EMPTY_TEXT), + Object.entries(values).filter(([, value]) => !NO_TEXT.has(value)), ); } @@ -89,8 +92,9 @@ export class LocalizationModifier extends ContainerModifier< } /** - * Stores an empty text as no text, so that the fallback locale answers for - * it in queries too (`unlockrequest`). + * Stores an empty text or `null` as no text, so that the fallback locale + * answers for it in queries too (`unlockrequest`). A read with no text + * answers `null`, so saving that answer again stores nothing. */ public override lock( // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the container modifier leaves the values by locale untyped @@ -99,7 +103,7 @@ export class LocalizationModifier extends ContainerModifier< value: unknown, key: string | undefined, ) { - return withoutEmptyTexts(super.lock(locked_value, value, key)); + return withoutMissingTexts(super.lock(locked_value, value, key)); } /** The value under `key`, else under the fallback locale. */ @@ -109,7 +113,7 @@ export class LocalizationModifier extends ContainerModifier< key: string | undefined, ) { const unlocked = super.unlock(locked_value, key); - if (unlocked !== undefined && unlocked !== EMPTY_TEXT) { + if (!NO_TEXT.has(unlocked)) { return unlocked; } if (this.options.fallbackLocale) { diff --git a/src/tests/end_of_read_null.test.ts b/src/tests/end_of_read_null.test.ts index 48dbe25..364ec60 100644 --- a/src/tests/end_of_read_null.test.ts +++ b/src/tests/end_of_read_null.test.ts @@ -4,7 +4,11 @@ import { Field, Table } from "../table"; import { attachModifier, fromDatabase, + fromPlainData, + lock, MixinSymbol, + toDatabase, + toPlainData, TwoWayModifier, unlock, } from "../modifiers/common"; @@ -69,4 +73,41 @@ describe("Modified fields - a read that finds no value", () => { expect(page.summary).to.equal(undefined); }); + + describe("written back", () => { + class Note extends Table.with(LocalizationModifier) { + @Localized({ fallbackLocale: "fr" }) + @Field("string") + declare title?: string; + } + + it("keeps the fallback after a read answer is saved again", () => { + const stored = { + _id: "note-1", + _internal: { meta: {}, data: { title: {} } }, + }; + const read = fromDatabase(stored, Note).localize("en"); + const resent = fromPlainData(toPlainData(read), Note).localize("en"); + lock(resent, LocalizationModifier, undefined, "en"); + const saved = fromDatabase(toDatabase(resent), Note).localize("fr"); + saved.title = "Salut"; + + expect(read.title).to.equal(null); + expect(saved.localize("en").title).to.equal("Salut"); + }); + + it("stores no text when lock writes back a read with no text", () => { + const stored = { + _id: "page-2", + _internal: { meta: {}, data: { summary: { fr: "Salut" } } }, + }; + const page = fromDatabase(stored, Page); + unlock(page, LocalizationModifier, ["summary"], "en"); + lock(page, LocalizationModifier, ["summary"], "en"); + + expect(toDatabase(page)._internal?.data.summary).to.deep.equal({ + fr: "Salut", + }); + }); + }); }); diff --git a/src/tests/localized_plain_data.test.ts b/src/tests/localized_plain_data.test.ts index 99b4de7..0707947 100644 --- a/src/tests/localized_plain_data.test.ts +++ b/src/tests/localized_plain_data.test.ts @@ -70,14 +70,15 @@ describe("Localized fields - plain data with values by locale", () => { expect(getInternal(read).data.displayName).to.deep.equal({ en: "Members" }); }); - it("stores a null value under the locale it is read with", async () => { + it("stores a null value as no text under the locale it is read with", async () => { const read = await stored( RoleModel.fromPlainData({ displayName: wording, tooltip: null }).localize( "en", ), ); - expect(getInternal(read).data.tooltip).to.deep.equal({ en: null }); + expect(getInternal(read).data.tooltip).to.deep.equal({}); + expect(read.localize("en").tooltip).to.equal(null); }); it("stores no record for a field encrypted inside its locales", async () => { From 7d9333a016a9d0d19804dcecfa91c47fbb722143 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Wed, 11 Nov 2026 20:44:41 +0000 Subject: [PATCH 3/4] fix(modifiers): read a stored null as null without unlocking it --- docs/3.table-modifiers.md | 2 +- skills/database-decorators-interface/SKILL.md | 2 +- src/modifiers/common.ts | 7 ++++--- src/tests/modifiers/encryption.test.ts | 20 ++++++++++++++++++- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/3.table-modifiers.md b/docs/3.table-modifiers.md index 68431a8..dcdf658 100644 --- a/docs/3.table-modifiers.md +++ b/docs/3.table-modifiers.md @@ -370,7 +370,7 @@ The generic parameters of `OneWayModifier` are: ### `TwoWayModifier` -A `TwoWayModifier` extends `OneWayModifier` with an `unlock` method, for a transformation that can be reversed, such as encryption: +A `TwoWayModifier` extends `OneWayModifier` with an `unlock` method, for a transformation that can be reversed, such as encryption. A read never passes `undefined` or `null` to `unlock`: a field that stores `null` reads `null`, and a field the row stores reads `null` when its modifiers find no value in it: ```ts docs-check import { diff --git a/skills/database-decorators-interface/SKILL.md b/skills/database-decorators-interface/SKILL.md index d743e7b..f15a6b6 100644 --- a/skills/database-decorators-interface/SKILL.md +++ b/skills/database-decorators-interface/SKILL.md @@ -135,7 +135,7 @@ class Chef extends Table.with( - Under `localize("*")` a localized field takes the record of texts by locale, and `undefined` or `null` clears every locale. A single text throws a `TypeError`, whether it is assigned or held from `fromPlainData` when `localize("*")` runs. - A localized value `""` or `null` is stored as no value: reads and queries in that locale get the `fallbackLocale` value. A row read with no text answers `null`, and saving that answer again stores nothing. - A `ContainerModifier` key of `undefined` is no key: writes store nothing under it, and reads and queries get no value. A localized field reads the `fallbackLocale` value, or `null` when there is none. -- A modified field the row stores reads `null` when its modifiers find no value, such as `localize("de")` with no German text and no `fallbackLocale`. A field the row does not store reads `undefined`. A custom `unlock` never receives `null` from the modifier above it. +- A modified field the row stores reads `null` when its modifiers find no value, such as `localize("de")` with no German text and no `fallbackLocale`. A field the row does not store reads `undefined`. A field that stores `null` reads `null`. A custom `unlock` never receives `undefined` or `null`. - `@Relation({ to: () => Target })` is declarative metadata only: no foreign-key enforcement, consumed by introspection tooling via `RelationStaticMetadata`. - `Model.validate` (and `{ validate: true }` on insert/update) only checks fields whose `@Field` type is an io-ts-style codec with `.decode`; plain string field tokens are skipped. Update validates with diff --git a/src/modifiers/common.ts b/src/modifiers/common.ts index ac5d3f3..693d2e0 100644 --- a/src/modifiers/common.ts +++ b/src/modifiers/common.ts @@ -138,8 +138,9 @@ export class ModifiersDynamicMetadata { /** * Reads a field through its modifiers. A field the row stores reads `null` - * when its modifiers find no value in it, as under a locale with no text. - * A field the row does not store, or cannot read yet, reads `undefined`. + * when its modifiers find no value in it, as under a locale with no text, + * or when it stores `null`: no modifier unlocks `null`. A field the row + * does not store, or cannot read yet, reads `undefined`. */ get(field: PropertyKey, internal: InternalRecord) { if (Object.hasOwn(this.floating, field)) { @@ -152,7 +153,7 @@ export class ModifiersDynamicMetadata { const stored = dataByKey(internal)[field]; let value = stored; for (const { id, modifier, metaRef } of list) { - if (isTwoWay(modifier) && value !== undefined) { + if (isTwoWay(modifier) && value !== undefined && value !== null) { if (unlockArgsOf(this, field)[id]) { value = withRef(internal, id, modifier, metaRef, (modifier) => modifier.unlock(value, ...unlockArgsOf(this, field)[id]), diff --git a/src/tests/modifiers/encryption.test.ts b/src/tests/modifiers/encryption.test.ts index 97b91e5..3ab4a87 100644 --- a/src/tests/modifiers/encryption.test.ts +++ b/src/tests/modifiers/encryption.test.ts @@ -1,7 +1,7 @@ import { expect } from "chai"; import { Field, Table } from "../../table"; import { Encrypted, EncryptionModifier } from "../../modifiers/encryption"; -import { attachModifier } from "../../modifiers/common"; +import { attachModifier, fromDatabase } from "../../modifiers/common"; describe("Modifiers - encryption", () => { it("creates encryption modifier", async () => { @@ -174,4 +174,22 @@ describe("Modifiers - encryption", () => { expect(note.body).to.equal(16); }); + it("reads a stored null as null, without decrypting it", () => { + class Vault extends Table.with(EncryptionModifier) { + @Encrypted({ secretKey: "12345678901234567890123456789012" }) + @Field("string") + declare secret?: string | null; + } + const stored = { + _id: "vault-1", + _internal: { meta: {}, data: { secret: null } }, + }; + + const vault = fromDatabase(stored, Vault); + + expect(vault.secret).to.equal(null); + expect(JSON.parse(JSON.stringify(vault))).to.deep.include({ + secret: null, + }); + }); }); From ca69b81a34bd5995b5beabd4d8ed66abae812377 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Wed, 11 Nov 2026 20:49:17 +0000 Subject: [PATCH 4/4] fix(modifiers): unlock no undefined or null when assigning a field or testing a value --- src/modifiers/common.ts | 16 +++++++++------- src/tests/end_of_read_null.test.ts | 17 +++++++++++++++++ src/tests/modifiers/encryption.test.ts | 7 ++++++- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/modifiers/common.ts b/src/modifiers/common.ts index 693d2e0..2694f22 100644 --- a/src/modifiers/common.ts +++ b/src/modifiers/common.ts @@ -64,6 +64,9 @@ export type ModifierEvent = ( ...args: unknown[] ) => void; +/** The stored values no modifier unlocks: a field holding one reads `null`. */ +const NO_VALUE = new Set([undefined, null]); + interface ModifiedField { id: string; modifier: Modifier; @@ -153,7 +156,7 @@ export class ModifiersDynamicMetadata { const stored = dataByKey(internal)[field]; let value = stored; for (const { id, modifier, metaRef } of list) { - if (isTwoWay(modifier) && value !== undefined && value !== null) { + if (isTwoWay(modifier) && !NO_VALUE.has(value)) { if (unlockArgsOf(this, field)[id]) { value = withRef(internal, id, modifier, metaRef, (modifier) => modifier.unlock(value, ...unlockArgsOf(this, field)[id]), @@ -197,11 +200,10 @@ export class ModifiersDynamicMetadata { const { id, modifier, metaRef } = list[i]; prevlist[i] = { id, modifier, metaRef, prev }; if (isTwoWay(modifier)) { - if (unlockArgsOf(this, field)[id]) { - prev = modifier.unlock(prev, ...unlockArgsOf(this, field)[id]); - } else { - prev = undefined; - } + prev = + unlockArgsOf(this, field)[id] && !NO_VALUE.has(prev) + ? modifier.unlock(prev, ...unlockArgsOf(this, field)[id]) + : undefined; } } prevlist.push({ @@ -514,7 +516,7 @@ export function testValue( modifier.test(val, value, ...args), ); } - if (isTwoWay(modifier) && val !== undefined) { + if (isTwoWay(modifier) && !NO_VALUE.has(val)) { return withRef(internal, id, modifier, metaRef, (modifier) => modifier.unlock(val, ...args), ); diff --git a/src/tests/end_of_read_null.test.ts b/src/tests/end_of_read_null.test.ts index 364ec60..86e68d9 100644 --- a/src/tests/end_of_read_null.test.ts +++ b/src/tests/end_of_read_null.test.ts @@ -74,6 +74,23 @@ describe("Modified fields - a read that finds no value", () => { expect(page.summary).to.equal(undefined); }); + it("takes a new value on a stacked field that stores nothing or null", () => { + const fresh = new Page().localize("en"); + const cleared = fromDatabase( + { _id: "page-3", _internal: { meta: {}, data: { title: null } } }, + Page, + ).localize("en"); + + fresh.title = "Hello"; + cleared.title = "Hello"; + + expect(fresh.title).to.equal("Hello"); + expect(cleared.title).to.equal("Hello"); + expect(toDatabase(cleared)._internal?.data.title).to.deep.equal({ + en: ["Hello"], + }); + }); + describe("written back", () => { class Note extends Table.with(LocalizationModifier) { @Localized({ fallbackLocale: "fr" }) diff --git a/src/tests/modifiers/encryption.test.ts b/src/tests/modifiers/encryption.test.ts index 3ab4a87..b0fc76a 100644 --- a/src/tests/modifiers/encryption.test.ts +++ b/src/tests/modifiers/encryption.test.ts @@ -1,7 +1,11 @@ import { expect } from "chai"; import { Field, Table } from "../../table"; import { Encrypted, EncryptionModifier } from "../../modifiers/encryption"; -import { attachModifier, fromDatabase } from "../../modifiers/common"; +import { + attachModifier, + fromDatabase, + testValue, +} from "../../modifiers/common"; describe("Modifiers - encryption", () => { it("creates encryption modifier", async () => { @@ -188,6 +192,7 @@ describe("Modifiers - encryption", () => { const vault = fromDatabase(stored, Vault); expect(vault.secret).to.equal(null); + expect(testValue(vault, "secret", null)).to.equal(true); expect(JSON.parse(JSON.stringify(vault))).to.deep.include({ secret: null, });