@antelopejs/interface-database-decorators 0.1.11 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +17 -13
  2. package/dist/antelope.test.js +1 -1
  3. package/dist/common.d.ts +48 -2
  4. package/dist/common.js +51 -5
  5. package/dist/common.js.map +1 -1
  6. package/dist/database.js +18 -3
  7. package/dist/database.js.map +1 -1
  8. package/dist/internal/bound-schema.d.ts +8 -0
  9. package/dist/internal/bound-schema.js +14 -0
  10. package/dist/internal/bound-schema.js.map +1 -0
  11. package/dist/model.d.ts +21 -8
  12. package/dist/model.js +34 -15
  13. package/dist/model.js.map +1 -1
  14. package/dist/modifiers/autodate.d.ts +2 -2
  15. package/dist/modifiers/autodate.js.map +1 -1
  16. package/dist/modifiers/common.d.ts +37 -20
  17. package/dist/modifiers/common.js +81 -68
  18. package/dist/modifiers/common.js.map +1 -1
  19. package/dist/modifiers/encryption.js +26 -14
  20. package/dist/modifiers/encryption.js.map +1 -1
  21. package/dist/modifiers/hash.d.ts +46 -4
  22. package/dist/modifiers/hash.js +175 -14
  23. package/dist/modifiers/hash.js.map +1 -1
  24. package/dist/modifiers/internal/kinds.d.ts +15 -1
  25. package/dist/modifiers/internal/kinds.js +35 -0
  26. package/dist/modifiers/internal/kinds.js.map +1 -1
  27. package/dist/modifiers/internal/records.d.ts +40 -5
  28. package/dist/modifiers/internal/records.js +70 -1
  29. package/dist/modifiers/internal/records.js.map +1 -1
  30. package/dist/modifiers/localization.js +12 -7
  31. package/dist/modifiers/localization.js.map +1 -1
  32. package/dist/relation.d.ts +5 -0
  33. package/dist/relation.js +11 -0
  34. package/dist/relation.js.map +1 -1
  35. package/dist/schema.js +5 -6
  36. package/dist/schema.js.map +1 -1
  37. package/dist/table.d.ts +3 -2
  38. package/dist/table.js +29 -13
  39. package/dist/table.js.map +1 -1
  40. package/dist/tests/cross_instance.test.js +5 -2
  41. package/dist/tests/cross_instance.test.js.map +1 -1
  42. package/dist/tests/database.test.js +42 -2
  43. package/dist/tests/database.test.js.map +1 -1
  44. package/dist/tests/hash_upgrade.test.d.ts +1 -0
  45. package/dist/tests/hash_upgrade.test.js +132 -0
  46. package/dist/tests/hash_upgrade.test.js.map +1 -0
  47. package/dist/tests/helpers/hash-rows.d.ts +40 -0
  48. package/dist/tests/helpers/hash-rows.js +41 -0
  49. package/dist/tests/helpers/hash-rows.js.map +1 -0
  50. package/dist/tests/helpers/other-instance.js.map +1 -1
  51. package/dist/tests/integration.test.js +15 -4
  52. package/dist/tests/integration.test.js.map +1 -1
  53. package/dist/tests/metadata_inheritance.test.d.ts +1 -0
  54. package/dist/tests/metadata_inheritance.test.js +212 -0
  55. package/dist/tests/metadata_inheritance.test.js.map +1 -0
  56. package/dist/tests/modifiers/encryption_date_marker.test.d.ts +1 -0
  57. package/dist/tests/modifiers/encryption_date_marker.test.js +78 -0
  58. package/dist/tests/modifiers/encryption_date_marker.test.js.map +1 -0
  59. package/dist/tests/modifiers/hash.test.js +295 -0
  60. package/dist/tests/modifiers/hash.test.js.map +1 -1
  61. package/dist/tests/modifiers/inherited_names.test.d.ts +1 -0
  62. package/dist/tests/modifiers/inherited_names.test.js +96 -0
  63. package/dist/tests/modifiers/inherited_names.test.js.map +1 -0
  64. package/dist/tests/own_key_lookups.test.d.ts +1 -0
  65. package/dist/tests/own_key_lookups.test.js +49 -0
  66. package/dist/tests/own_key_lookups.test.js.map +1 -0
  67. package/dist/tests/partial_update.test.d.ts +1 -0
  68. package/dist/tests/partial_update.test.js +55 -0
  69. package/dist/tests/partial_update.test.js.map +1 -0
  70. package/dist/tests/row_forms.test.d.ts +1 -0
  71. package/dist/tests/row_forms.test.js +59 -0
  72. package/dist/tests/row_forms.test.js.map +1 -0
  73. package/dist/tests/stored_metadata.test.d.ts +1 -0
  74. package/dist/tests/stored_metadata.test.js +91 -0
  75. package/dist/tests/stored_metadata.test.js.map +1 -0
  76. package/dist/tests/subclass_schema_binding.test.d.ts +1 -0
  77. package/dist/tests/subclass_schema_binding.test.js +115 -0
  78. package/dist/tests/subclass_schema_binding.test.js.map +1 -0
  79. package/dist/tests/table.test.js +16 -0
  80. package/dist/tests/table.test.js.map +1 -1
  81. package/docs/1.introduction.md +24 -20
  82. package/docs/2.table-definitions.md +63 -41
  83. package/docs/3.table-modifiers.md +128 -98
  84. package/docs/4.data-models.md +63 -52
  85. package/docs/5.parameter-decoration.md +28 -24
  86. package/docs/tsconfig.json +3 -0
  87. package/package.json +10 -9
  88. package/skills/database-decorators-interface/SKILL.md +6 -3
@@ -1,14 +1,18 @@
1
- # Table Modifiers
1
+ ---
2
+ title: Table modifiers
3
+ description: Reference for the encryption, hashing, localization, and auto-date modifiers, the scrypt hash format, and the classes and events for writing a custom modifier.
4
+ mode: reference
5
+ ---
2
6
 
3
- Table modifiers transform field values as they are stored in and retrieved from the database. They enable transparent encryption, hashing, localization, and custom data transformations. Add modifiers to a table class using the `Table.with()` method.
7
+ A table modifier transforms a field's value on its way into a row and on its way out. The built-in modifiers encrypt, hash, or localize a field, or set a timestamp. Add a modifier's mixin to a table class with `Table.with()`, then mark each field with the modifier's decorator.
4
8
 
5
- ## Built-in Modifiers
9
+ ## Built-in modifiers
6
10
 
7
- ### EncryptionModifier
11
+ ### `EncryptionModifier`
8
12
 
9
- The `EncryptionModifier` encrypts field values before storing them and decrypts them on retrieval. Both operations happen transparently.
13
+ The `EncryptionModifier` encrypts a field's value when the value is assigned and decrypts it when the field is read.
10
14
 
11
- The default `aes-256-gcm` algorithm requires a key of exactly 32 bytes. `secretKey` is passed to Node's cipher as a UTF-8 string, without hex or base64 decoding. Supply a high-entropy, 32-byte UTF-8 value from a secret manager, keep it stable for existing ciphertext, and fail startup if it is missing or invalid; a 64-character hex encoding is not a 32-byte key for this API.
15
+ The default `aes-256-gcm` algorithm needs a key of exactly 32 bytes. `secretKey` goes to Node's cipher as a UTF-8 string, without hex or base64 decoding. Supply a high-entropy, 32-byte UTF-8 value from a secret manager, keep it stable while ciphertext exists, and fail startup if it is missing or invalid. A 64-character hex encoding is 64 bytes for this API, not a 32-byte key.
12
16
 
13
17
  ```ts docs-check
14
18
  import {
@@ -41,13 +45,13 @@ class UserCredentials extends Table.with(EncryptionModifier) {
41
45
  }
42
46
  ```
43
47
 
44
- #### How It Works
48
+ #### How it works
45
49
 
46
- - Data is encrypted using the specified algorithm before database insertion.
47
- - A random initialization vector (IV) is generated for each write.
48
- - For authenticated encryption algorithms (e.g., `aes-256-gcm`), an auth tag is stored alongside the ciphertext.
49
- - On retrieval, the data is automatically decrypted.
50
- - Date objects are restored as `Date` instances, but the encoder uses `toUTCString()`, which drops milliseconds. Encrypted dates round-trip only to whole-second precision.
50
+ - An assignment encodes the value as JSON and encrypts it with the configured algorithm. The row stores `[ciphertext, iv, authTag]`, each in base64.
51
+ - Each write generates a random initialization vector (IV).
52
+ - An authenticated algorithm, such as `aes-256-gcm`, also stores its auth tag. Other algorithms store no tag.
53
+ - A read decrypts the value.
54
+ - A `Date` comes back as a `Date` instance. The encoder writes it with `toUTCString()`, which drops milliseconds, so an encrypted date keeps whole-second precision only.
51
55
 
52
56
  #### Options
53
57
 
@@ -57,9 +61,9 @@ class UserCredentials extends Table.with(EncryptionModifier) {
57
61
  | `algorithm` | `string` | `"aes-256-gcm"` | Encryption algorithm. |
58
62
  | `ivSize` | `number` | `16` | Initialization vector size in bytes. |
59
63
 
60
- ### HashModifier
64
+ ### `HashModifier`
61
65
 
62
- The `HashModifier` stores a salted one-way hash of field values. Unlike encryption, hashing is irreversible -- you cannot retrieve the original value. Its default SHA-256 hash is fast, not a password-hardening function; use a dedicated password hashing library for production credentials.
66
+ The `HashModifier` stores a salted one-way hash of a field's value. A hash cannot be reversed, so the original value cannot be read back. By default the field stores a scrypt hash, a memory-hard function made for passwords.
63
67
 
64
68
  ```typescript
65
69
  import {
@@ -73,43 +77,68 @@ class User extends Table.with(HashModifier) {
73
77
  @Field("string")
74
78
  declare email: string;
75
79
 
76
- @Hashed({ algorithm: "sha256" })
80
+ @Hashed()
77
81
  @Field("string")
78
82
  declare password: string;
79
83
 
80
- verifyPassword(plainPassword: string): boolean {
81
- return this.testHash("password", plainPassword);
84
+ verifyPassword(plainPassword: string): Promise<boolean> {
85
+ return this.testHashAsync("password", plainPassword);
82
86
  }
83
87
  }
84
88
  ```
85
89
 
86
- #### How It Works
90
+ #### How it works
87
91
 
88
- - A random salt is generated and stored in the field's metadata on first write.
89
- - The value is hashed with the salt using the specified algorithm.
90
- - The `testHash` method (added by the mixin) hashes a candidate value with the stored salt and compares the result.
92
+ - The field hashes the value encoded as JSON, so `"secret"` is hashed as the text `"\"secret\""`.
93
+ - Each write draws a random 16-byte salt and stores `$scrypt$ln=15,r=8,p=3$<salt>$<key>`. The salt and the 32-byte key are base64 without padding. `ln` is the base-2 logarithm of the scrypt cost N, so N is 32768. The string holds the parameters, the salt, and the key, so the cost can change later.
94
+ - A write is synchronous, because a property setter cannot wait, and so are the conversions behind a model's `insert` and `update`. Each assignment blocks the event loop for one scrypt run, which uses 32 MiB (128 × N × r bytes).
95
+ - Assigning the stored hash back to the field, or calling `lock` on a loaded row, keeps the hash as it is. Assigning `undefined` stores nothing.
96
+ - `testHash` and `testHashAsync` read `ln`, `r`, `p`, the salt, and the key from the stored string. They derive a key of the stored key's length from the candidate and compare the two with `crypto.timingSafeEqual`, in constant time.
97
+ - A stored scrypt string verifies only within these limits: `ln`, `r`, and `p` from 1 to 99, at most 128 MiB of memory, `p` at most 16, `ln` below 16 × `r`, and a salt and a key of 16 to 64 bytes each. A string outside them refuses every candidate.
98
+ - On a field without an `algorithm`, a test spends one scrypt run whatever the row holds, a hash in another format or nothing included. The time of a refusal does not tell whether the row holds a current hash.
91
99
 
92
100
  #### Options
93
101
 
94
- | Option | Type | Default | Description |
95
- | ----------- | -------- | ---------- | ------------------ |
96
- | `algorithm` | `string` | `"sha256"` | Hashing algorithm. |
102
+ | Option | Type | Default | Description |
103
+ | ----------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
104
+ | `algorithm` | `string` | none | Digest to store instead of scrypt, any name `createHash` accepts. A digest is fast to guess, so use it only for non-secret fingerprints, never for credentials. See the next list. |
97
105
 
98
- `LocalizationModifier.fallbackLocaleOf(TableClass, field)` returns a field's `fallbackLocale`, or `undefined` when the field is not localized or declares none. Generated forms use it to require a value in that locale.
106
+ A field with an `algorithm` stores the base64 digest of the row's salt followed by the JSON-encoded value. The salt is a random string that the first such write creates. The row keeps it in `_internal.meta.HashModifier.salt` and reuses it for every later write to its digest fields. A test of a digest field that holds a digest spends no scrypt run. The field still verifies a scrypt string it holds, with one scrypt run, and `isHashOutdated` never flags it, so a scrypt hash is never flagged for replacement by a weaker digest.
99
107
 
100
- #### Mixin Method
108
+ #### Mixin methods
101
109
 
102
- The `HashModifier` mixin adds a `testHash` method to table instances:
110
+ The `HashModifier` mixin adds these methods to table instances:
103
111
 
104
- ```typescript
105
- testHash(field: string, value: unknown): boolean
112
+ ```typescript fragment
113
+ testHash<This extends {}, F extends keyof This>(this: This, field: F, value: This[F]): unknown
114
+ testHashAsync<This extends {}, F extends keyof This>(this: This, field: F, value: This[F]): Promise<boolean>
115
+ isHashOutdated<This extends {}>(this: This, field: keyof This): boolean
116
+ ```
117
+
118
+ - `testHashAsync` resolves whether `value` matches the stored hash. It runs scrypt on the thread pool, so use it on a server. It rejects with `Field <name> is not hashed` for a field without `@Hashed`.
119
+ - `testHash` gives the same result and blocks the event loop while scrypt runs. Its declared return type is `unknown`. On a hashed field, the value is a boolean.
120
+ - `isHashOutdated` returns whether the stored hash differs from what the field writes today, which is any stored string that does not start with `$scrypt$ln=15,r=8,p=3$`. It returns `false` when nothing is stored, and always on a field with an `algorithm`. It throws `Field <name> is not hashed` for a field without `@Hashed`.
121
+
122
+ #### Upgrade stored hashes
123
+
124
+ A row whose hash has another format still verifies. On a field without an `algorithm`, a string that is not a scrypt hash is tested as a salted SHA-256 digest, made with the salt in `_internal.meta.HashModifier.salt`, and `isHashOutdated` returns `true` for it. A scrypt hash of another cost verifies with its own parameters and is flagged too. To rewrite a flagged hash, assign the value after a successful test, because only then is the plaintext known:
125
+
126
+ ```typescript fragment
127
+ if (await user.testHashAsync("password", password)) {
128
+ if (user.isHashOutdated("password")) {
129
+ user.password = password;
130
+ await userModel.update(user);
131
+ }
132
+ }
106
133
  ```
107
134
 
108
- Returns `true` if `value`, when hashed, matches the stored hash for the given field.
135
+ `userModel.update(user._id, { password })` rewrites the hash too. An update that leaves the hashed field out, by ID or with the whole row, keeps the stored hash.
109
136
 
110
- ### LocalizationModifier
137
+ To move a credential off `@Hashed({ algorithm: "sha256" })`, remove the option. Its rows keep verifying, and `isHashOutdated` flags them. A row written with another digest verifies only while the field declares that digest.
111
138
 
112
- The `LocalizationModifier` stores multiple language versions of a field value. Access a specific locale by calling `localize()` on the table instance.
139
+ ### `LocalizationModifier`
140
+
141
+ The `LocalizationModifier` stores one value of a field per locale. To read or write a locale, call `localize()` on the table instance.
113
142
 
114
143
  ```typescript
115
144
  import {
@@ -133,9 +162,11 @@ class Product extends Table.with(LocalizationModifier) {
133
162
  }
134
163
  ```
135
164
 
165
+ `LocalizationModifier.fallbackLocaleOf(TableClass, field)` returns a field's `fallbackLocale`, or `undefined` when the field is not localized or declares none. Generated forms use it to require a value in that locale.
166
+
136
167
  #### Usage
137
168
 
138
- ```typescript
169
+ ```typescript fragment
139
170
  const product = new Product();
140
171
  product._id = "prod-123";
141
172
  product.price = 99.99;
@@ -149,30 +180,29 @@ product.localize("es").name = "Auriculares Premium";
149
180
  const spanish = product.localize("es");
150
181
  console.log(spanish.name); // "Auriculares Premium"
151
182
 
152
- // Fallback to default locale when translation is missing
183
+ // Fall back to the default locale when a translation is missing
153
184
  const german = product.localize("de");
154
185
  console.log(german.name); // "Premium Headphones" (fallback)
155
186
  ```
156
187
 
157
- #### Wildcard Key
188
+ #### Wildcard key
158
189
 
159
- The wildcard key `"*"` retrieves or sets all translations at once at runtime. `localize()` does not change the property's TypeScript type, so a field declared as `string` needs an explicit view for bulk access:
190
+ The wildcard key `"*"` reads or writes all translations at once. `localize()` does not change the property's TypeScript type, so a field declared as `string` holds a record of texts by locale under `"*"`. Read that record as `unknown` and check it before use. Write it with `Object.assign`, which calls the field's setter:
160
191
 
161
- ```typescript
192
+ ```typescript fragment
162
193
  // Get all translations
163
- const translations = product.localize("*") as unknown as {
164
- name: Record<string, string>;
165
- };
166
- const allNames = translations.name;
194
+ const allNames: unknown = product.localize("*").name;
167
195
  // { en: "Premium Headphones", fr: "Casque Premium", es: "Auriculares Premium" }
168
196
 
169
197
  // Set all translations
170
- translations.name = {
171
- en: "Premium Headphones",
172
- fr: "Casque Premium",
173
- es: "Auriculares Premium",
174
- de: "Premium-Kopfhoerer",
175
- };
198
+ Object.assign(product.localize("*"), {
199
+ name: {
200
+ en: "Premium Headphones",
201
+ fr: "Casque Premium",
202
+ es: "Auriculares Premium",
203
+ de: "Premium-Kopfhoerer",
204
+ },
205
+ });
176
206
  ```
177
207
 
178
208
  #### Options
@@ -181,23 +211,23 @@ translations.name = {
181
211
  | ---------------- | -------- | --------------------------------------------------------------------- |
182
212
  | `fallbackLocale` | `string` | Locale to use when the requested locale has no value or an empty one. |
183
213
 
184
- #### Mixin Method
214
+ #### Mixin method
185
215
 
186
216
  The `LocalizationModifier` mixin adds a `localize` method:
187
217
 
188
- ```typescript
189
- localize(locale: string, fields?: Array<keyof this>): this
218
+ ```typescript fragment
219
+ localize<This extends { constructor: any }>(this: This, locale: string, fields?: Array<keyof This>): This
190
220
  ```
191
221
 
192
- `localize()` changes the active locale and returns the same object, not a locale-specific copy.
222
+ `localize()` changes the active locale and returns the same object, not a copy for that locale.
193
223
 
194
224
  - **References share state.** `spanish`, `german`, and `product` above refer to one object. A later `localize()` call changes what all three references read.
195
- - **Fields can be restricted.** When you supply `fields`, only those fields are unlocked for the selected locale.
196
- - **Copy values before switching.** Save the values you need before changing locales, and do not share one instance between concurrent locale-specific operations.
225
+ - **Fields can be restricted.** When you supply `fields`, only those fields switch to the selected locale.
226
+ - **Copy values before switching.** Save the values you need before you change locales, and do not share one instance between concurrent operations on different locales.
197
227
 
198
- ### AutoDateModifier
228
+ ### `AutoDateModifier`
199
229
 
200
- The `AutoDateModifier` automatically populates timestamp fields during insert and update operations. Use the `CreationTime` and `UpdateTime` decorators to mark fields.
230
+ The `AutoDateModifier` sets timestamp fields on insert and update. Mark the fields with the `CreationTime` and `UpdateTime` decorators.
201
231
 
202
232
  ```typescript
203
233
  import {
@@ -217,15 +247,15 @@ class Article extends Table {
217
247
  }
218
248
  ```
219
249
 
220
- #### How It Works
250
+ #### How it works
221
251
 
222
- - On insert, both `CreationTime` and `UpdateTime` fields are set to the current date.
223
- - On update, `UpdateTime` fields are refreshed while `CreationTime` fields are removed from the payload so the original creation date is preserved.
224
- - This is an event-only modifier: it does not transform stored values and does not require adding a mixin through `Table.with()`.
252
+ - On insert, both `CreationTime` and `UpdateTime` fields get the current date.
253
+ - On update, `UpdateTime` fields get the current date, and `CreationTime` fields are removed from the payload, so the stored creation date stays.
254
+ - The modifier only handles events. It does not transform stored values and has no mixin, so the table class needs no `Table.with()`.
225
255
 
226
- ## Combine Multiple Modifiers
256
+ ## Combine multiple modifiers
227
257
 
228
- Pass multiple modifier classes to `Table.with()`:
258
+ Pass several modifier classes to `Table.with()`:
229
259
 
230
260
  ```typescript
231
261
  import {
@@ -266,11 +296,11 @@ class UserProfile extends Table.with(
266
296
  }
267
297
  ```
268
298
 
269
- ## Create Custom Modifiers
299
+ ## Create custom modifiers
270
300
 
271
- ### OneWayModifier
301
+ ### `OneWayModifier`
272
302
 
273
- A `OneWayModifier` transforms data in one direction (when writing to the database). Use it for irreversible transformations like hashing.
303
+ A `OneWayModifier` transforms data in one direction, when a value is written. Use it for a transformation that cannot be reversed, such as hashing.
274
304
 
275
305
  ```typescript
276
306
  import {
@@ -311,18 +341,18 @@ const PrefixHashed = MakePropertyDecorator(
311
341
  );
312
342
  ```
313
343
 
314
- The generic parameters for `OneWayModifier<LockedType, Args, Meta, Options>` are:
344
+ The generic parameters of `OneWayModifier<LockedType, Args, Meta, Options>` are:
315
345
 
316
- | Parameter | Description |
317
- | ------------ | ---------------------------------------------------- |
318
- | `LockedType` | The type stored in the database after transformation |
319
- | `Args` | Additional arguments for `lock` and `test` |
320
- | `Meta` | Per-field metadata stored alongside the value |
321
- | `Options` | Configuration passed via the decorator |
346
+ | Parameter | Description |
347
+ | ------------ | -------------------------------------------------------------------------------------------------------- |
348
+ | `LockedType` | The type stored in the database after the transformation. |
349
+ | `Args` | Additional arguments for `lock` and `test`. Defaults to `[]`. |
350
+ | `Meta` | The modifier's state for a row, kept in `_internal.meta.<modifier class name>` and shared by its fields. |
351
+ | `Options` | The configuration that the decorator passes to `attachModifier`. |
322
352
 
323
- ### TwoWayModifier
353
+ ### `TwoWayModifier`
324
354
 
325
- A `TwoWayModifier` extends `OneWayModifier` with an `unlock` method for reversible transformations like encryption:
355
+ A `TwoWayModifier` extends `OneWayModifier` with an `unlock` method, for a transformation that can be reversed, such as encryption:
326
356
 
327
357
  ```ts docs-check
328
358
  import {
@@ -334,7 +364,7 @@ import { MakePropertyDecorator } from "@antelopejs/interface-core/decorators";
334
364
  import type { ValueProxy } from "@antelopejs/interface-database";
335
365
 
336
366
  class Base64Modifier extends TwoWayModifier<
337
- string | undefined,
367
+ unknown,
338
368
  [],
339
369
  {},
340
370
  { prefix?: string }
@@ -342,23 +372,23 @@ class Base64Modifier extends TwoWayModifier<
342
372
  public readonly autolock = true;
343
373
  public readonly autounlock = true;
344
374
 
345
- public override lock(_locked: string | undefined, value: unknown) {
375
+ public override lock(_locked: unknown, value: unknown) {
346
376
  if (value === undefined) return undefined;
347
377
  const prefix = this.options.prefix || "b64-";
348
378
  return `${prefix}${Buffer.from(JSON.stringify(value)).toString("base64")}`;
349
379
  }
350
380
 
351
- public override unlock(locked: string | undefined): unknown {
352
- if (locked === undefined) return undefined;
381
+ public override unlock(locked: unknown): unknown {
382
+ if (typeof locked !== "string") return undefined;
353
383
  const prefix = this.options.prefix || "b64-";
354
384
  const encoded = locked.substring(prefix.length);
355
385
  return JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
356
386
  }
357
387
 
358
388
  public override unlockrequest(
359
- data: ValueProxy<string | undefined>,
389
+ data: ValueProxy<unknown>,
360
390
  ): ValueProxy<unknown> {
361
- return data.cast<unknown>();
391
+ return data;
362
392
  }
363
393
 
364
394
  [MixinSymbol] = class {};
@@ -376,11 +406,11 @@ const Base64Encoded = MakePropertyDecorator(
376
406
  );
377
407
  ```
378
408
 
379
- Base64 is encoding, not encryption. This example's `unlockrequest()` leaves the encoded value unchanged in database expressions; only instance reads decode it.
409
+ 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`.
380
410
 
381
- ### ContainerModifier
411
+ ### `ContainerModifier`
382
412
 
383
- For modifiers that store multiple values in a key-value structure (like `LocalizationModifier`), extend `ContainerModifier`. It provides built-in support for the wildcard key `"*"` for bulk operations:
413
+ A modifier that stores several values under keys, as `LocalizationModifier` does, extends `ContainerModifier`. Its `lock`, `unlock`, and `unlockrequest` take the key as their argument. `lock` and `unlock` treat the wildcard key `"*"` as the whole record:
384
414
 
385
415
  ```typescript
386
416
  import { ContainerModifier } from "@antelopejs/interface-database-decorators";
@@ -391,18 +421,18 @@ class MyContainerModifier extends ContainerModifier<{}, { fallback?: string }> {
391
421
  }
392
422
  ```
393
423
 
394
- ## Modifier Events
424
+ ## Modifier events
395
425
 
396
- Modifiers can hook into the data lifecycle by implementing event handler methods. These methods are called during data conversion and database operations:
426
+ A modifier hooks into a row's lifecycle through methods named after events. The engine calls each one with the modifier as `this`, then the table instance, the field name, and the event's arguments:
397
427
 
398
- | Event | Triggered When |
399
- | --------------- | ------------------------------------------ |
400
- | `fromPlainData` | After converting plain data to an instance |
401
- | `toPlainData` | After converting an instance to plain data |
402
- | `fromDatabase` | After reading from the database |
403
- | `toDatabase` | Before writing to the database |
404
- | `insert` | Before inserting a new record |
405
- | `update` | Before updating an existing record |
428
+ | Event | Triggered when |
429
+ | --------------- | --------------------------------------------------------------------- |
430
+ | `fromPlainData` | After plain data is converted to an instance. |
431
+ | `toPlainData` | After an instance is converted to plain data. |
432
+ | `fromDatabase` | After a stored record is converted to an instance, as reads do. |
433
+ | `toDatabase` | When an instance is converted to the record to store, before a write. |
434
+ | `insert` | Before a model or a fixture inserts a new record. |
435
+ | `update` | Before a model updates an existing record. |
406
436
 
407
437
  ```typescript
408
438
  import {
@@ -411,15 +441,15 @@ import {
411
441
  } from "@antelopejs/interface-database-decorators";
412
442
 
413
443
  class TimestampModifier extends Modifier {
414
- public insert(instance: any, field: string) {
415
- if (field === "createdAt" && !instance[field]) {
416
- instance[field] = new Date();
444
+ public insert<F extends string>(object: Partial<Record<F, Date>>, field: F) {
445
+ if (field === "createdAt" && !object[field]) {
446
+ object[field] = new Date();
417
447
  }
418
448
  }
419
449
 
420
- public update(instance: any, field: string) {
450
+ public update<F extends string>(object: Partial<Record<F, Date>>, field: F) {
421
451
  if (field === "updatedAt") {
422
- instance[field] = new Date();
452
+ object[field] = new Date();
423
453
  }
424
454
  }
425
455
 
@@ -427,4 +457,4 @@ class TimestampModifier extends Modifier {
427
457
  }
428
458
  ```
429
459
 
430
- Event-only modifiers (without `lock`/`unlock`) are valid and useful for side effects like logging, validation, or auto-populating fields.
460
+ A modifier without `lock` and `unlock` only handles events. `attachModifier` then defines no accessor on the field, which keeps its plain value. Such a modifier fits side effects such as logging, validation, or filling in fields.