From a883c14bd0bad47cb8d9287dfb0c5f38dbfcb0f7 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Tue, 10 Nov 2026 22:24:03 +0000 Subject: [PATCH 1/6] test(localized): read an unset field of a request without a locale as null --- src/tests/localized_no_key.test.ts | 51 ++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/tests/localized_no_key.test.ts diff --git a/src/tests/localized_no_key.test.ts b/src/tests/localized_no_key.test.ts new file mode 100644 index 0000000..129f768 --- /dev/null +++ b/src/tests/localized_no_key.test.ts @@ -0,0 +1,51 @@ +import { expect } from "chai"; +import { Schema } from "@antelopejs/interface-database"; +import { RegisterTable } from "../schema"; +import { RegisterSchema } from "../database"; +import { Field, Table } from "../table"; +import { fromDatabase, lock, unlock } from "../modifiers/common"; +import { LocalizationModifier, Localized } from "../modifiers/localization"; + +describe("Localized fields - a request without a locale", () => { + @RegisterTable("no-key-pages", "localized-no-key-schema") + class Page extends Table.with(LocalizationModifier) { + @Localized() @Field("string") declare title?: string; + @Localized({ fallbackLocale: "en" }) + @Field("string") + declare summary?: string; + } + + async function createdWithoutLocale() { + await RegisterSchema("localized-no-key-schema"); + const instance = Schema.get("localized-no-key-schema")?.instance(); + if (!instance) throw new Error("Database not found"); + const row = new Page(); + row._id = "page-1"; + lock(row, LocalizationModifier, undefined, undefined); + row.title = undefined; + row.summary = undefined; + await Page.table(instance).insert(row).run(); + const read = fromDatabase( + await Page.table(instance).get("page-1").run(), + Page, + ); + unlock(read, LocalizationModifier, undefined, undefined); + return read; + } + + afterEach(async () => { + await Schema.get("localized-no-key-schema") + ?.instance() + .table("no-key-pages") + .delete() + .run(); + }); + + it("answers null for a field the request did not send", async () => { + const answer: unknown = JSON.parse( + JSON.stringify(await createdWithoutLocale()), + ); + + expect(answer).to.deep.include({ title: null, summary: null }); + }); +}); From 628bb9ed7beb80b5dc6de9d3b8d52a5a1db1ad77 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Tue, 10 Nov 2026 22:24:32 +0000 Subject: [PATCH 2/6] fix(localized): read an unset field of a request without a locale as null --- src/modifiers/localization.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/modifiers/localization.ts b/src/modifiers/localization.ts index 7db7a3b..45740f7 100644 --- a/src/modifiers/localization.ts +++ b/src/modifiers/localization.ts @@ -94,6 +94,10 @@ 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. + */ 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, @@ -103,9 +107,10 @@ export class LocalizationModifier extends ContainerModifier< if (unlocked !== undefined && unlocked !== EMPTY_TEXT) { return unlocked; } - if (this.options.fallbackLocale) { - return super.unlock(locked_value, this.options.fallbackLocale); - } + const fallback = this.options.fallbackLocale + ? super.unlock(locked_value, this.options.fallbackLocale) + : undefined; + return key === undefined ? (fallback ?? null) : fallback; } public override unlockrequest( From 8ea905dec1ffa3036c785516b7031ef440644ac1 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Tue, 10 Nov 2026 22:25:21 +0000 Subject: [PATCH 3/6] docs(localized): say an unset field read with no locale answers null --- docs/3.table-modifiers.md | 2 +- skills/database-decorators-interface/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/3.table-modifiers.md b/docs/3.table-modifiers.md index efe0f3f..c46df6f 100644 --- a/docs/3.table-modifiers.md +++ b/docs/3.table-modifiers.md @@ -426,7 +426,7 @@ Base64 is an encoding, not encryption. The example declares its `LockedType` as ### `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`: +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: ```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 6eaaeca..93e7c49 100644 --- a/skills/database-decorators-interface/SKILL.md +++ b/skills/database-decorators-interface/SKILL.md @@ -133,7 +133,7 @@ class Chef extends Table.with( - `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 `ContainerModifier` key of `undefined` is no key: writes store nothing under it, and reads and queries get no value, or the `fallbackLocale` value for a localized field. +- 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. - `@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 From 1d89f519c1e66db0da9119c4c43d31d4a825efcb Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Tue, 10 Nov 2026 22:29:52 +0000 Subject: [PATCH 4/6] test(localized): read an unset encrypted localized field without a locale as null --- src/tests/localized_no_key.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/tests/localized_no_key.test.ts b/src/tests/localized_no_key.test.ts index 129f768..471755e 100644 --- a/src/tests/localized_no_key.test.ts +++ b/src/tests/localized_no_key.test.ts @@ -4,15 +4,20 @@ import { RegisterTable } from "../schema"; import { RegisterSchema } from "../database"; import { Field, Table } from "../table"; import { fromDatabase, lock, unlock } from "../modifiers/common"; +import { EncryptionModifier, Encrypted } from "../modifiers/encryption"; import { LocalizationModifier, Localized } from "../modifiers/localization"; describe("Localized fields - a request without a locale", () => { @RegisterTable("no-key-pages", "localized-no-key-schema") - class Page extends Table.with(LocalizationModifier) { + class Page extends Table.with(LocalizationModifier, EncryptionModifier) { @Localized() @Field("string") declare title?: string; @Localized({ fallbackLocale: "en" }) @Field("string") declare summary?: string; + @Localized() + @Encrypted({ secretKey: "12345678901234567890123456789012" }) + @Field("string") + declare secret?: string; } async function createdWithoutLocale() { @@ -21,9 +26,10 @@ describe("Localized fields - a request without a locale", () => { if (!instance) throw new Error("Database not found"); const row = new Page(); row._id = "page-1"; - lock(row, LocalizationModifier, undefined, undefined); row.title = undefined; row.summary = undefined; + row.secret = "Sent without a locale"; + lock(row, LocalizationModifier, undefined, undefined); await Page.table(instance).insert(row).run(); const read = fromDatabase( await Page.table(instance).get("page-1").run(), @@ -46,6 +52,10 @@ describe("Localized fields - a request without a locale", () => { JSON.stringify(await createdWithoutLocale()), ); - expect(answer).to.deep.include({ title: null, summary: null }); + expect(answer).to.deep.include({ + title: null, + summary: null, + secret: null, + }); }); }); From b4b6f17c28018dbce78f37153e03fd8228c76ae3 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Tue, 10 Nov 2026 22:29:53 +0000 Subject: [PATCH 5/6] fix(modifiers): end a field read at a modifier that unlocks to null --- src/modifiers/common.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/modifiers/common.ts b/src/modifiers/common.ts index 5dbbf0e..2b46fb1 100644 --- a/src/modifiers/common.ts +++ b/src/modifiers/common.ts @@ -136,6 +136,10 @@ export class ModifiersDynamicMetadata { ); } + /** + * Reads a field through its modifiers. A modifier that unlocks the value to + * `null` ends the read: the modifiers after it do not unlock it. + */ get(field: PropertyKey, internal: InternalRecord) { if (Object.hasOwn(this.floating, field)) { return floatingByKey(this)[field]; @@ -147,14 +151,16 @@ export class ModifiersDynamicMetadata { let value = dataByKey(internal)[field]; for (const { id, modifier, metaRef } of list) { if (isTwoWay(modifier) && value !== undefined) { - if (unlockArgsOf(this, field)[id]) { - value = withRef(internal, id, modifier, metaRef, (modifier) => - modifier.unlock(value, ...unlockArgsOf(this, field)[id]), - ); - } else if ("autounlock" in modifier && modifier.autounlock) { + const args = + unlockArgsOf(this, field)[id] ?? + ("autounlock" in modifier && modifier.autounlock ? [] : undefined); + if (args) { value = withRef(internal, id, modifier, metaRef, (modifier) => - modifier.unlock(value), + modifier.unlock(value, ...args), ); + if (value === null) { + break; + } } } } From 666c60f7e01d662849440d87cd779aa72af12888 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Tue, 10 Nov 2026 22:35:08 +0000 Subject: [PATCH 6/6] fix(encryption): read null as no value instead of ending every field read at null --- src/modifiers/common.ts | 18 ++++++------------ src/modifiers/encryption.ts | 10 +++++++++- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/modifiers/common.ts b/src/modifiers/common.ts index 2b46fb1..5dbbf0e 100644 --- a/src/modifiers/common.ts +++ b/src/modifiers/common.ts @@ -136,10 +136,6 @@ export class ModifiersDynamicMetadata { ); } - /** - * Reads a field through its modifiers. A modifier that unlocks the value to - * `null` ends the read: the modifiers after it do not unlock it. - */ get(field: PropertyKey, internal: InternalRecord) { if (Object.hasOwn(this.floating, field)) { return floatingByKey(this)[field]; @@ -151,16 +147,14 @@ export class ModifiersDynamicMetadata { let value = dataByKey(internal)[field]; for (const { id, modifier, metaRef } of list) { if (isTwoWay(modifier) && value !== undefined) { - const args = - unlockArgsOf(this, field)[id] ?? - ("autounlock" in modifier && modifier.autounlock ? [] : undefined); - if (args) { + if (unlockArgsOf(this, field)[id]) { + value = withRef(internal, id, modifier, metaRef, (modifier) => + modifier.unlock(value, ...unlockArgsOf(this, field)[id]), + ); + } else if ("autounlock" in modifier && modifier.autounlock) { value = withRef(internal, id, modifier, metaRef, (modifier) => - modifier.unlock(value, ...args), + modifier.unlock(value), ); - if (value === null) { - break; - } } } } diff --git a/src/modifiers/encryption.ts b/src/modifiers/encryption.ts index b6dd516..b4f9725 100644 --- a/src/modifiers/encryption.ts +++ b/src/modifiers/encryption.ts @@ -152,7 +152,15 @@ export class EncryptionModifier extends TwoWayModifier< readonly autounlock = true; - public override unlock([locked_value, iv_str, authTag]: LockedType) { + /** + * 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; const decipher = createDecipheriv( this.options.algorithm || "aes-256-gcm", this.options.secretKey,