@antelopejs/interface-database-decorators 0.1.12 → 0.2.1

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 (76) hide show
  1. package/README.md +11 -9
  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 +1 -2
  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 +10 -7
  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 +77 -60
  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.js +4 -1
  22. package/dist/modifiers/hash.js.map +1 -1
  23. package/dist/modifiers/internal/kinds.d.ts +14 -1
  24. package/dist/modifiers/internal/kinds.js +31 -0
  25. package/dist/modifiers/internal/kinds.js.map +1 -1
  26. package/dist/modifiers/internal/records.d.ts +40 -7
  27. package/dist/modifiers/internal/records.js +69 -14
  28. package/dist/modifiers/internal/records.js.map +1 -1
  29. package/dist/modifiers/localization.js +9 -2
  30. package/dist/modifiers/localization.js.map +1 -1
  31. package/dist/relation.d.ts +5 -0
  32. package/dist/relation.js +11 -0
  33. package/dist/relation.js.map +1 -1
  34. package/dist/schema.js +5 -6
  35. package/dist/schema.js.map +1 -1
  36. package/dist/table.d.ts +3 -2
  37. package/dist/table.js +27 -12
  38. package/dist/table.js.map +1 -1
  39. package/dist/tests/cross_instance.test.js +5 -2
  40. package/dist/tests/cross_instance.test.js.map +1 -1
  41. package/dist/tests/database.test.js +42 -34
  42. package/dist/tests/database.test.js.map +1 -1
  43. package/dist/tests/helpers/other-instance.js.map +1 -1
  44. package/dist/tests/integration.test.js +15 -4
  45. package/dist/tests/integration.test.js.map +1 -1
  46. package/dist/tests/metadata_inheritance.test.d.ts +1 -0
  47. package/dist/tests/metadata_inheritance.test.js +212 -0
  48. package/dist/tests/metadata_inheritance.test.js.map +1 -0
  49. package/dist/tests/modifiers/encryption_date_marker.test.d.ts +1 -0
  50. package/dist/tests/modifiers/encryption_date_marker.test.js +78 -0
  51. package/dist/tests/modifiers/encryption_date_marker.test.js.map +1 -0
  52. package/dist/tests/modifiers/inherited_names.test.d.ts +1 -0
  53. package/dist/tests/modifiers/inherited_names.test.js +96 -0
  54. package/dist/tests/modifiers/inherited_names.test.js.map +1 -0
  55. package/dist/tests/own_key_lookups.test.d.ts +1 -0
  56. package/dist/tests/own_key_lookups.test.js +49 -0
  57. package/dist/tests/own_key_lookups.test.js.map +1 -0
  58. package/dist/tests/row_forms.test.d.ts +1 -0
  59. package/dist/tests/row_forms.test.js +59 -0
  60. package/dist/tests/row_forms.test.js.map +1 -0
  61. package/dist/tests/stored_metadata.test.d.ts +1 -0
  62. package/dist/tests/stored_metadata.test.js +91 -0
  63. package/dist/tests/stored_metadata.test.js.map +1 -0
  64. package/dist/tests/subclass_schema_binding.test.d.ts +1 -0
  65. package/dist/tests/subclass_schema_binding.test.js +115 -0
  66. package/dist/tests/subclass_schema_binding.test.js.map +1 -0
  67. package/dist/tests/table.test.js +16 -0
  68. package/dist/tests/table.test.js.map +1 -1
  69. package/docs/1.introduction.md +24 -20
  70. package/docs/2.table-definitions.md +63 -41
  71. package/docs/3.table-modifiers.md +113 -104
  72. package/docs/4.data-models.md +63 -52
  73. package/docs/5.parameter-decoration.md +28 -24
  74. package/docs/tsconfig.json +3 -0
  75. package/package.json +10 -9
  76. package/skills/database-decorators-interface/SKILL.md +3 -2
@@ -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. By default it stores a scrypt hash, a memory-hard function fit for passwords.
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 {
@@ -83,39 +87,43 @@ class User extends Table.with(HashModifier) {
83
87
  }
84
88
  ```
85
89
 
86
- #### How It Works
90
+ #### How it works
87
91
 
88
- - Each write generates a random salt and stores `$scrypt$ln=15,r=8,p=3$<salt>$<key>` in the field. The string holds the algorithm, the cost, the salt and the hash, so the cost can change later.
89
- - Assigning the stored hash back to the field, or calling `lock` on a loaded row, keeps the hash as it is.
90
- - `testHash` and `testHashAsync` read the cost and the salt from the stored string, hash the candidate and compare in constant time.
91
- - A test spends one scrypt run whatever the row holds, so the time of a refusal does not tell whether a hash exists.
92
- - A write is synchronous, because a property setter cannot wait. Assigning a value blocks the event loop for one scrypt run, about 150 ms and 32 MiB.
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.
93
99
 
94
100
  #### Options
95
101
 
96
- | Option | Type | Default | Description |
97
- | ----------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
98
- | `algorithm` | `string` | none | Digest to store instead of scrypt, any name `createHash` accepts. A digest is fast to guess: use it only for non-secret fingerprints, never for credentials. Rows stay in the bare digest format. |
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. |
99
105
 
100
- #### Mixin Methods
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.
107
+
108
+ #### Mixin methods
101
109
 
102
110
  The `HashModifier` mixin adds these methods to table instances:
103
111
 
104
- ```typescript
105
- testHash(field: string, value: unknown): boolean
106
- testHashAsync(field: string, value: unknown): Promise<boolean>
107
- isHashOutdated(field: string): 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
108
116
  ```
109
117
 
110
- - `testHashAsync` returns whether `value` matches the stored hash. It runs scrypt on the thread pool, so use it on a server.
111
- - `testHash` returns the same result and blocks the event loop while scrypt runs.
112
- - `isHashOutdated` returns whether the stored hash differs from what the field writes today. It is always `false` on a field with an `algorithm`, and when nothing is stored.
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`.
113
121
 
114
- #### Upgrade Stored Hashes
122
+ #### Upgrade stored hashes
115
123
 
116
- A row written before scrypt became the default holds a bare salted SHA-256. It still verifies, and `isHashOutdated` returns `true` for it. The same applies to a hash of an earlier cost. To upgrade a row, assign the value after a successful test, because that is the only moment the plaintext is known:
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:
117
125
 
118
- ```typescript
126
+ ```typescript fragment
119
127
  if (await user.testHashAsync("password", password)) {
120
128
  if (user.isHashOutdated("password")) {
121
129
  user.password = password;
@@ -124,13 +132,13 @@ if (await user.testHashAsync("password", password)) {
124
132
  }
125
133
  ```
126
134
 
127
- To move a credential off `@Hashed({ algorithm: "sha256" })`, remove the option. Its rows keep verifying and are flagged for upgrade. A row written with another digest verifies only while the field declares that digest.
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.
128
136
 
129
- `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.
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.
130
138
 
131
- ### LocalizationModifier
139
+ ### `LocalizationModifier`
132
140
 
133
- The `LocalizationModifier` stores multiple language versions of a field value. Access a specific locale by calling `localize()` on the table instance.
141
+ The `LocalizationModifier` stores one value of a field per locale. To read or write a locale, call `localize()` on the table instance.
134
142
 
135
143
  ```typescript
136
144
  import {
@@ -154,9 +162,11 @@ class Product extends Table.with(LocalizationModifier) {
154
162
  }
155
163
  ```
156
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
+
157
167
  #### Usage
158
168
 
159
- ```typescript
169
+ ```typescript fragment
160
170
  const product = new Product();
161
171
  product._id = "prod-123";
162
172
  product.price = 99.99;
@@ -170,30 +180,29 @@ product.localize("es").name = "Auriculares Premium";
170
180
  const spanish = product.localize("es");
171
181
  console.log(spanish.name); // "Auriculares Premium"
172
182
 
173
- // Fallback to default locale when translation is missing
183
+ // Fall back to the default locale when a translation is missing
174
184
  const german = product.localize("de");
175
185
  console.log(german.name); // "Premium Headphones" (fallback)
176
186
  ```
177
187
 
178
- #### Wildcard Key
188
+ #### Wildcard key
179
189
 
180
- 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:
181
191
 
182
- ```typescript
192
+ ```typescript fragment
183
193
  // Get all translations
184
- const translations = product.localize("*") as unknown as {
185
- name: Record<string, string>;
186
- };
187
- const allNames = translations.name;
194
+ const allNames: unknown = product.localize("*").name;
188
195
  // { en: "Premium Headphones", fr: "Casque Premium", es: "Auriculares Premium" }
189
196
 
190
197
  // Set all translations
191
- translations.name = {
192
- en: "Premium Headphones",
193
- fr: "Casque Premium",
194
- es: "Auriculares Premium",
195
- de: "Premium-Kopfhoerer",
196
- };
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
+ });
197
206
  ```
198
207
 
199
208
  #### Options
@@ -202,23 +211,23 @@ translations.name = {
202
211
  | ---------------- | -------- | --------------------------------------------------------------------- |
203
212
  | `fallbackLocale` | `string` | Locale to use when the requested locale has no value or an empty one. |
204
213
 
205
- #### Mixin Method
214
+ #### Mixin method
206
215
 
207
216
  The `LocalizationModifier` mixin adds a `localize` method:
208
217
 
209
- ```typescript
210
- 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
211
220
  ```
212
221
 
213
- `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.
214
223
 
215
224
  - **References share state.** `spanish`, `german`, and `product` above refer to one object. A later `localize()` call changes what all three references read.
216
- - **Fields can be restricted.** When you supply `fields`, only those fields are unlocked for the selected locale.
217
- - **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.
218
227
 
219
- ### AutoDateModifier
228
+ ### `AutoDateModifier`
220
229
 
221
- 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.
222
231
 
223
232
  ```typescript
224
233
  import {
@@ -238,15 +247,15 @@ class Article extends Table {
238
247
  }
239
248
  ```
240
249
 
241
- #### How It Works
250
+ #### How it works
242
251
 
243
- - On insert, both `CreationTime` and `UpdateTime` fields are set to the current date.
244
- - On update, `UpdateTime` fields are refreshed while `CreationTime` fields are removed from the payload so the original creation date is preserved.
245
- - 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()`.
246
255
 
247
- ## Combine Multiple Modifiers
256
+ ## Combine multiple modifiers
248
257
 
249
- Pass multiple modifier classes to `Table.with()`:
258
+ Pass several modifier classes to `Table.with()`:
250
259
 
251
260
  ```typescript
252
261
  import {
@@ -287,11 +296,11 @@ class UserProfile extends Table.with(
287
296
  }
288
297
  ```
289
298
 
290
- ## Create Custom Modifiers
299
+ ## Create custom modifiers
291
300
 
292
- ### OneWayModifier
301
+ ### `OneWayModifier`
293
302
 
294
- 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.
295
304
 
296
305
  ```typescript
297
306
  import {
@@ -332,18 +341,18 @@ const PrefixHashed = MakePropertyDecorator(
332
341
  );
333
342
  ```
334
343
 
335
- The generic parameters for `OneWayModifier<LockedType, Args, Meta, Options>` are:
344
+ The generic parameters of `OneWayModifier<LockedType, Args, Meta, Options>` are:
336
345
 
337
- | Parameter | Description |
338
- | ------------ | ---------------------------------------------------- |
339
- | `LockedType` | The type stored in the database after transformation |
340
- | `Args` | Additional arguments for `lock` and `test` |
341
- | `Meta` | Per-field metadata stored alongside the value |
342
- | `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`. |
343
352
 
344
- ### TwoWayModifier
353
+ ### `TwoWayModifier`
345
354
 
346
- 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:
347
356
 
348
357
  ```ts docs-check
349
358
  import {
@@ -355,7 +364,7 @@ import { MakePropertyDecorator } from "@antelopejs/interface-core/decorators";
355
364
  import type { ValueProxy } from "@antelopejs/interface-database";
356
365
 
357
366
  class Base64Modifier extends TwoWayModifier<
358
- string | undefined,
367
+ unknown,
359
368
  [],
360
369
  {},
361
370
  { prefix?: string }
@@ -363,23 +372,23 @@ class Base64Modifier extends TwoWayModifier<
363
372
  public readonly autolock = true;
364
373
  public readonly autounlock = true;
365
374
 
366
- public override lock(_locked: string | undefined, value: unknown) {
375
+ public override lock(_locked: unknown, value: unknown) {
367
376
  if (value === undefined) return undefined;
368
377
  const prefix = this.options.prefix || "b64-";
369
378
  return `${prefix}${Buffer.from(JSON.stringify(value)).toString("base64")}`;
370
379
  }
371
380
 
372
- public override unlock(locked: string | undefined): unknown {
373
- if (locked === undefined) return undefined;
381
+ public override unlock(locked: unknown): unknown {
382
+ if (typeof locked !== "string") return undefined;
374
383
  const prefix = this.options.prefix || "b64-";
375
384
  const encoded = locked.substring(prefix.length);
376
385
  return JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
377
386
  }
378
387
 
379
388
  public override unlockrequest(
380
- data: ValueProxy<string | undefined>,
389
+ data: ValueProxy<unknown>,
381
390
  ): ValueProxy<unknown> {
382
- return data.cast<unknown>();
391
+ return data;
383
392
  }
384
393
 
385
394
  [MixinSymbol] = class {};
@@ -397,11 +406,11 @@ const Base64Encoded = MakePropertyDecorator(
397
406
  );
398
407
  ```
399
408
 
400
- 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`.
401
410
 
402
- ### ContainerModifier
411
+ ### `ContainerModifier`
403
412
 
404
- 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:
405
414
 
406
415
  ```typescript
407
416
  import { ContainerModifier } from "@antelopejs/interface-database-decorators";
@@ -412,18 +421,18 @@ class MyContainerModifier extends ContainerModifier<{}, { fallback?: string }> {
412
421
  }
413
422
  ```
414
423
 
415
- ## Modifier Events
424
+ ## Modifier events
416
425
 
417
- 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:
418
427
 
419
- | Event | Triggered When |
420
- | --------------- | ------------------------------------------ |
421
- | `fromPlainData` | After converting plain data to an instance |
422
- | `toPlainData` | After converting an instance to plain data |
423
- | `fromDatabase` | After reading from the database |
424
- | `toDatabase` | Before writing to the database |
425
- | `insert` | Before inserting a new record |
426
- | `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. |
427
436
 
428
437
  ```typescript
429
438
  import {
@@ -432,15 +441,15 @@ import {
432
441
  } from "@antelopejs/interface-database-decorators";
433
442
 
434
443
  class TimestampModifier extends Modifier {
435
- public insert(instance: any, field: string) {
436
- if (field === "createdAt" && !instance[field]) {
437
- 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();
438
447
  }
439
448
  }
440
449
 
441
- public update(instance: any, field: string) {
450
+ public update<F extends string>(object: Partial<Record<F, Date>>, field: F) {
442
451
  if (field === "updatedAt") {
443
- instance[field] = new Date();
452
+ object[field] = new Date();
444
453
  }
445
454
  }
446
455
 
@@ -448,4 +457,4 @@ class TimestampModifier extends Modifier {
448
457
  }
449
458
  ```
450
459
 
451
- 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.