Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/3.table-modifiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion skills/database-decorators-interface/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ class Chef extends Table.with(
- `instance.localize("*", ["motto"])` reads and writes every locale at once, typed `Record<string, string>` for each listed field (`LocalizedRecords<T, K>`); 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
Expand Down
10 changes: 9 additions & 1 deletion src/modifiers/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 8 additions & 3 deletions src/modifiers/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
Expand All @@ -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(
Expand Down
61 changes: 61 additions & 0 deletions src/tests/localized_no_key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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 { 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, 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() {
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";
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(),
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,
secret: null,
});
});
});
Loading