diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90ab75f..9ee1268 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ permissions: jobs: ci: - uses: AntelopeJS/.github/.github/workflows/ci-node.yml@v1 + uses: AntelopeJS/.github/.github/workflows/ci-node.yml@v1-campaign with: docs-files: | README.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 801443c..fa9374f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,10 @@ on: options: - latest - next + release-version: + description: Exact stable version; empty uses the automatic increment + required: false + type: string permissions: contents: write @@ -21,5 +25,6 @@ jobs: uses: AntelopeJS/.github/.github/workflows/release-npm-public.yml@v1 with: channel: ${{ inputs.channel }} + release-version: ${{ inputs.release-version }} secrets: RELEASE_GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index ec80c8a..a803ecc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ The release commit writes `CHANGELOG.md`. Upgrade notes for that version follow ## Guardrails -Finish every change with `pnpm run checks`. It runs typecheck, lint, format, knip, tests, size, ratchet, suppressions, and sync, and prints only what failed. The change is done when it prints no failure. +Finish every change with `pnpm run checks`. It runs typecheck, lint, format, knip, tests, size, suppressions, and sync, and prints only what failed. The change is done when it prints no failure. The tools enforce most rules, listed in the table at the end. The sections below hold the rules no tool checks. @@ -69,10 +69,6 @@ Verify a boundary against the imports a developer or agent would plausibly write `guardrails.suppressions.json` caps the suppressions in authored code: lint directives per rule they name (`*` for none), `@ts-expect-error`, `@ts-ignore` and `SAFETY:` comments, and under `config` each rule a lint config (`oxlint.config.*`, `.oxlintrc.json`, `eslint.config.*`) switches off or to a warning, keyed by rule and `files` glob anchored at the config's directory, so widening or moving a scope is a new ceiling of 0, while narrowing one takes over the room it gives up. Switching a rule off in a config is a suppression too: fix the code its rule reports instead. A suppression over its ceiling fails: fix the code, or remove another suppression of the same kind. A rule that keeps needing suppressions should be changed in the preset. Never raise or remove a number in that file: the check fails a ceiling higher than on the pull request base, and only the operator approves a raise. A directive disabling `anti-slop/require-safety-comment-for-type-assertion` is burn-down debt: remove the assertion, or, where it holds, replace the directive with `SAFETY: `, which takes over its room without a raise. After you remove suppressions, run `pnpm run suppressions:update` and commit the lowered file. -### Debt - -`guardrails.ratchet.json` records the lint findings and oversize files this repository already had. Its numbers only go down. After you remove debt, or after a pack upgrade, run `pnpm run ratchet:update` and commit the file: it lowers the numbers and adds only the rules the new pack release enabled, at today's count. Never edit the numbers by hand; CI fails one added or raised against the base. A rule listed there is off for `oxlint`. The ratchet counts it instead, and the pre-commit hook rejects it on every line you add. - ### What enforces what | Rule | Enforced by | @@ -114,6 +110,5 @@ Verify a boundary against the imports a developer or agent would plausibly write | Entry-only rules exempt no tracked outsider | `antelopejs-sync --check` | | A DMS layer's `build/backend-boundary.ts` is the pack's plugin | `antelopejs-sync --check` | | A test names the wrong implementation it catches | review; no tool | -| Existing debt only shrinks | `antelopejs-ratchet` | diff --git a/README.md b/README.md index a9c4b31..eced919 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ -# @antelopejs/interface-database-decorators +# `@antelopejs/interface-database-decorators`
-npm -License -Discord -Docs +npm +License +Discord +Docs
-Database tables and data models as TypeScript classes, on top of [`@antelopejs/interface-database`](https://github.com/AntelopeJS/interface-database): declare fields and indexes with decorators, then read and write rows through a typed model. +Database tables and data models as TypeScript classes, on top of [`@antelopejs/interface-database`](https://github.com/AntelopeJS/interface-database). Declare fields and indexes with decorators, then read and write rows through a typed model. Part of [AntelopeJS](https://github.com/AntelopeJS/antelopejs). Used by [AntelopeJS DMS](https://github.com/AntelopeJS/dms), the open-source back office for your PostgreSQL, MongoDB or RethinkDB database. @@ -90,15 +90,15 @@ export async function start() { ## Compatibility -Requires AntelopeJS core `1.x` and the following peer dependencies: +The package declares these `@antelopejs/*` peer dependencies: | Package | Supported | Tested with | | -------------------------------- | ----------------- | ----------- | | `@antelopejs/interface-api` | `>=0.0.15 <1.0.0` | `0.0.15` | -| `@antelopejs/interface-core` | `>=0.2.4 <1.0.0` | `0.2.4` | -| `@antelopejs/interface-database` | `>=0.1.11 <1.0.0` | `0.1.11` | +| `@antelopejs/interface-core` | `>=0.2.4 <1.0.0` | `0.4.0` | +| `@antelopejs/interface-database` | `>=0.1.11 <1.0.0` | `0.3.0` | diff --git a/docs/1.introduction.md b/docs/1.introduction.md index f424f05..1e69013 100644 --- a/docs/1.introduction.md +++ b/docs/1.introduction.md @@ -1,21 +1,25 @@ -# Define tables and work with models +--- +title: Define tables and work with models +description: Describe a table as a TypeScript class, register its schema, and save and read a first row through a typed model. +mode: how-to +--- ## Overview -Use `@antelopejs/interface-database-decorators` to describe tables with TypeScript classes and work with converted records through models. It builds on AQL (`@antelopejs/interface-database`) and adds field modifiers and API model injection. +`@antelopejs/interface-database-decorators` describes tables as TypeScript classes and reads and writes converted rows through models. It builds on AQL (`@antelopejs/interface-database`) and adds field modifiers and model injection into API controllers. ## From a table class to a stored record 1. **Describe the table.** Extend `Table`, declare fields and indexes, and register the class with `@RegisterTable("users", "myapp")`. See [table definitions](./2.table-definitions.md). -2. **Bind the schema.** Import the table declarations, then call `await RegisterSchema("myapp")` from their declaring module. This requires a configured database provider and can provision tables and insert fixtures; it is not a read-only metadata operation. -3. **Create the model.** Use `BasicDataModel(User)` and a `SchemaInstance`, or `GetModel` after the table has been bound. See [data models](./4.data-models.md). -4. **Write and read through the model.** Model operations apply conversion and modifier events. Use `model.table` when you need to compose an AQL query yourself. +2. **Bind the schema.** Import the table declarations, then call `await RegisterSchema("myapp")` from their declaring module. The call needs a configured database module. It provisions the tables and inserts fixtures, so it writes to the database. +3. **Create the model.** Use `BasicDataModel(User)` with a `SchemaInstance`, or call `GetModel` once the table is bound. See [data models](./4.data-models.md). +4. **Write and read through the model.** Model operations apply the conversions and the modifier events. To compose an AQL query yourself, use `model.table`. -Keep schema IDs and instance IDs distinct: `myapp` identifies the table definitions; an instance ID selects the database instance used by a model. A table class without a schema binding cannot be resolved by `GetModel`. +Keep schema IDs and instance IDs apart. `myapp` names the table definitions, and an instance ID selects the database instance a model uses. `GetModel` throws for a table class that `RegisterSchema` has not bound, unless a schema of the class's schema name already exists. ### Save and retrieve a note -In a module with a configured database provider, import this declaration before calling `tryNote()` during initialization. Use a disposable development database: `RegisterSchema` provisions the schema, and `insert` writes data. +In a module with a configured database module, import this declaration, then call `tryNote()` during initialization. Use a disposable development database, because `RegisterSchema` provisions the schema and `insert` writes a row. ```ts docs-check import { @@ -45,7 +49,7 @@ export async function tryNote() { } ``` -The model uses the default instance of the schema bound to `Note`. Keep the inserted ID when extending this exercise with `update` or `delete`; each successful run inserts a new note. In an application, register the schema once during initialization rather than on each request. +The model uses the default instance of the schema bound to `Note`. Each run inserts a new note, so keep the inserted ID if you extend this exercise with `update` or `delete`. In an application, register the schema once during initialization, not on each request. ## Choose the layer for your task @@ -54,23 +58,23 @@ The model uses the default instance of the schema bound to `Note`. Keep the inse | Describe storage fields and indexes | [Table definitions](./2.table-definitions.md) | Declaring a class is separate from registering its schema. | | Encrypt, hash, or localize fields | [Table modifiers](./3.table-modifiers.md) | Conversion events determine when transformations run. | | Read or write converted records | [Data models](./4.data-models.md) | Reads return converted records, not chainable queries. | -| Select a model in an HTTP request | [Parameter decoration](./5.parameter-decoration.md) | The instance ID selects a binding; it does not authorize access. | +| Select a model in an HTTP request | [Parameter decoration](./5.parameter-decoration.md) | The instance ID selects a binding. It does not authorize access. | -Start without modifiers until the schema and model work together. Add validation explicitly: string-token `@Field` declarations describe storage, while codec validation requires codecs and the model's validation options. +Start without modifiers until the schema and the model work together. Validation is opt-in. A string-token `@Field` describes storage only, and codec validation needs io-ts codecs and the model's `validate` option. ## Dependencies -This package depends on the following: +The package depends on these packages: -- [**@antelopejs/interface-database**](https://github.com/AntelopeJS/interface-database) - The underlying database query interface (AQL) -- [**@antelopejs/interface-api**](https://github.com/AntelopeJS/interface-api) - API controller integration for parameter decoration -- [**@antelopejs/interface-core**](https://github.com/AntelopeJS/interface-core) - Core decorator utilities -- [**reflect-metadata**](https://www.npmjs.com/package/reflect-metadata) - Metadata reflection for TypeScript decorators -- [**randomstring**](https://www.npmjs.com/package/randomstring) - Random string generation for hash salts +- [`@antelopejs/interface-database`](https://github.com/AntelopeJS/interface-database) is the database query interface (AQL). +- [`@antelopejs/interface-api`](https://github.com/AntelopeJS/interface-api) provides the API controllers that `@Model` injects into. +- [`@antelopejs/interface-core`](https://github.com/AntelopeJS/interface-core) provides the decorator factories. +- [`reflect-metadata`](https://www.npmjs.com/package/reflect-metadata) stores the decorator metadata. +- [`randomstring`](https://www.npmjs.com/package/randomstring) generates the salt of a `@Hashed` field that sets an `algorithm`. ## Documentation -- [Table Definitions](./2.table-definitions.md) - Define database tables with the `Table` class, `Index` decorator, and `Fixture` decorator -- [Table Modifiers](./3.table-modifiers.md) - Apply encryption, hashing, and localization modifiers to table fields, or create custom modifiers -- [Data Models](./4.data-models.md) - Create data models with `BasicDataModel` for CRUD operations and data conversion -- [Parameter Decoration](./5.parameter-decoration.md) - Inject model instances into API controller methods with the `Model` decorator +- [Table definitions](./2.table-definitions.md) covers the `Table` class and the `Field`, `Index`, `Relation`, `RegisterTable`, and `Fixture` decorators. +- [Table modifiers](./3.table-modifiers.md) covers encryption, hashing, and localization of fields, and custom modifiers. +- [Data models](./4.data-models.md) covers `BasicDataModel`, its CRUD methods, and the data conversions. +- [Parameter decoration](./5.parameter-decoration.md) covers the `Model` decorator, which injects a model into API controller methods. diff --git a/docs/2.table-definitions.md b/docs/2.table-definitions.md index ba78eb9..992c847 100644 --- a/docs/2.table-definitions.md +++ b/docs/2.table-definitions.md @@ -1,10 +1,14 @@ -# Table Definitions +--- +title: Table definitions +description: Reference for the Table class and the Field, Index, Relation, RegisterTable, and Fixture decorators, RegisterSchema, and Table.table. +mode: reference +--- -The Database Decorators package provides a class-based approach to defining database tables. You extend the `Table` base class, declare each field with TypeScript's `declare` keyword and the `@Field` decorator, and use further decorators to configure indexes, relations, and initial data. +A table is a class that extends `Table`. Each field is a property declared with TypeScript's `declare` keyword and the `@Field` decorator. Further decorators add indexes, relations, and initial data. -## The Table Class +## The `Table` class -The `Table` class serves as the base class for all table definitions. Every table automatically includes an `_id` field as the primary key. +`Table` is the base class of every table definition. It declares `_id: string`, the primary key of every table. ```typescript import { Table, Field } from "@antelopejs/interface-database-decorators"; @@ -18,9 +22,9 @@ class User extends Table { } ``` -### Extend with Modifiers +### Extend with modifiers -The `Table.with()` static method incorporates modifier mixins into the table class, adding capabilities like encryption, hashing, or localization: +The static `Table.with()` method returns a subclass of the table class that holds the mixin methods of the modifiers it receives, such as encryption, hashing, or localization: ```typescript import { @@ -45,11 +49,11 @@ class SensitiveData extends Table.with(EncryptionModifier) { } ``` -Provide a high-entropy key through your secret manager; do not use a default key. See [Table Modifiers](./3.table-modifiers.md#encryptionmodifier) for key format and date precision constraints. +Provide a high-entropy key through your secret manager, and never a default key. See [Table modifiers](./3.table-modifiers.md#encryptionmodifier) for the key format and the date precision limit. -## The Index Decorator +## The `Index` decorator -The `Index` decorator marks a field as a database index. Indexed fields enable efficient lookups through `getAll()` and `between()` on the underlying AQL table. +The `Index` decorator marks a field as a database index. An indexed field serves lookups through `getAll()` and `between()` on the AQL table, and through the model's `getBy()`. `@Index` on a symbol-keyed property throws, because a database index names a field. ```typescript import { Table, Field, Index } from "@antelopejs/interface-database-decorators"; @@ -72,13 +76,15 @@ class User extends Table { | `crossInstance` | `boolean` | Also keep the index fast for queries that run across all instances. | | `unique` | `boolean` | Refuse two rows of the instance with the same key. | -### Cross-Instance Indexes +### Cross-instance indexes -Every index is scoped to the schema instance, so queries on a single instance always use it. Set `crossInstance: true` when the index is also queried across all instances (queries on the `CROSS_INSTANCE` instance), so the database driver maintains an additional index for those queries. +Every index is scoped to the schema instance. Set `crossInstance: true` when queries across all instances (on the `CROSS_INSTANCE` instance) also use the index. The database driver then maintains an additional index for those queries. -The option never changes query results. Without it, cross-instance queries that name the index still return the same rows but run slower, and the driver logs a warning once per index. Leave it unset for indexes that are only queried within one instance, since each extra index costs storage and write time. +The option never changes query results. Without it, a cross-instance query that names the index returns the same rows but runs slower, and the `@antelopejs/mongodb` and `@antelopejs/rethinkdb` drivers log a warning once per index. Leave it unset for an index that only queries within one instance use, because each extra index costs storage and write time. ```typescript +import { Field, Index, Table } from "@antelopejs/interface-database-decorators"; + class User extends Table { @Index({ crossInstance: true }) @Field("string") @@ -88,11 +94,13 @@ class User extends Table { For a compound index, setting `crossInstance: true` on any field of the group applies it to the whole group. -### Unique Indexes +### Unique indexes -Set `unique: true` to refuse a second row with the same key in one instance. The write that would store it throws `DuplicateKeyError` from `@antelopejs/interface-database`, naming the index and its fields; test it with `IsDuplicateKeyError`. Rows of different instances may share a key. +Set `unique: true` to refuse a second row with the same key in one instance. The write that would store it throws `DuplicateKeyError` from `@antelopejs/interface-database`, which names the index and its fields. Test for it with `IsDuplicateKeyError`. Rows of different instances may share a key. ```typescript +import { Field, Index, Table } from "@antelopejs/interface-database-decorators"; + class Template extends Table { @Index({ unique: true }) @Field("string") @@ -100,9 +108,9 @@ class Template extends Table { } ``` -For a compound index, setting `unique: true` on any field of the group makes the whole group's key unique. Not every database module can enforce unique secondary indexes: one that cannot refuses the schema rather than ignoring the option. +For a compound index, setting `unique: true` on any field of the group makes the whole group's key unique. Not every database module can enforce a unique secondary index. A module that cannot, such as `@antelopejs/rethinkdb`, refuses the schema instead of ignoring the option. -### Compound Indexes +### Compound indexes Assign multiple fields to the same index group to create a compound index: @@ -134,11 +142,11 @@ class UserActivity extends Table { } ``` -## The Field Decorator +## The `Field` decorator -`@Field(type)` declares the field type for a property. The type is forwarded verbatim into the `TableDefinition.fields` map handed to the adapter. It accepts the full `FieldType` union -- primitive tokens (`"string"`, `"number"`, ...), arrays, nested records, or an io-ts codec. +`@Field(type)` declares the type of a property. The type goes unchanged into the `TableDefinition.fields` map that the database module receives. It accepts every member of the `FieldType` union, for example a string token such as `"string"` or `"number"`, an array, a nested record, or an io-ts codec. -```typescript +```typescript fragment import * as t from "io-ts"; import { Table, @@ -161,13 +169,13 @@ class Order extends Table { } ``` -Properties without `@Field` are omitted from `fields`. `@Field` stacks freely with `@Index`. +Properties without `@Field` are left out of `fields`. `@Field` and `@Index` each write their own metadata, so they combine in either order. -## The Relation Decorator +## The `Relation` decorator -The `Relation` decorator declares a link from a field to another table. It is **declarative metadata only**: database implementations do not enforce it (no foreign key constraint, no referential validation). Introspection tooling consumes it to expose the links between tables — for example to render schema diagrams or navigate related records. +The `Relation` decorator declares a link from a field to another table. It is metadata only. Database modules do not enforce it, with no foreign key constraint and no check that the target exists. Introspection tools read it to show the links between tables, for example in a schema diagram or to navigate to related records. -```typescript +```typescript fragment import { Table, Field, @@ -195,11 +203,11 @@ class Comment extends Table { | `toField` | `string` | Target field name. Defaults to the target table's primary key. | | `many` | `boolean` | The decorated field holds multiple target keys (many targets per source record). | -`@Relation` stacks freely with `@Field` and `@Index`. +`@Relation` writes its own metadata, so it combines with `@Field` and `@Index` in any order. -## The RegisterTable Decorator +## The `RegisterTable` decorator -The `RegisterTable` class decorator associates a table class with a specific table name and schema. This registration is used by `RegisterSchema` to build the schema definition automatically. +The `RegisterTable` class decorator gives a table class its table name and its schema. `RegisterSchema` builds the schema definition from these registrations. ```typescript import { @@ -222,9 +230,9 @@ class User extends Table { The first argument is the table name in the database, and the second is the schema name. -## The Fixture Decorator +## The `Fixture` decorator -The `Fixture` decorator defines default data to insert when a table is first created. It receives a generator function that produces initial records. +The `Fixture` decorator defines the rows to insert into an empty table. It receives a generator function that returns them. ```typescript import { @@ -246,13 +254,21 @@ class UserRole extends Table { The generator function: -- Receives the table class as its parameter. -- Can return a single object, an array of objects, or a `Promise` resolving to either. -- Runs only when the table is empty, preventing duplicate inserts on subsequent starts. +- Receives the table class registered into the schema, which is the decorated class or a subclass of it. +- Can return a single object, an array of objects, or a `Promise` of either. +- Runs when `RegisterSchema` finds the table empty, so a later start inserts nothing again. -### Async Generators +Only a table registered with `@RegisterTable` gets its fixture, from `RegisterSchema`. Each row goes through the same conversion as a model insert, so modifiers and the `insert` event apply. A row without an `_id` gets `:fixture:`, so two processes that start on the same empty table write one row between them. + +### Async generators + +```typescript fragment +import { + Field, + Fixture, + Table, +} from "@antelopejs/interface-database-decorators"; -```typescript @Fixture(async () => { const defaults = await loadDefaultConfig(); return defaults.map((cfg) => ({ @@ -268,7 +284,7 @@ class SystemConfig extends Table { ## Inheritance -Tables support standard class inheritance. Define a base table with common fields and extend it for specific use cases: +A table class can extend another table class. The subclass starts with a copy of its parent's table and schema names, fields, indexes, and fixture, so decorators on the subclass do not change the parent: ```typescript import { @@ -312,11 +328,11 @@ class Post extends BaseEntity { } ``` -## Register a Schema +## Register a schema -The `RegisterSchema` function builds a `SchemaDefinition` from all tables registered for the given schema id (via `@RegisterTable`), constructs the `Schema` (which the adapter provisions), binds each table class to that `Schema` (see [Open a Table from its Class](#open-a-table-from-its-class)), then inserts any fixture data for tables that are empty. +The `RegisterSchema` function builds a `SchemaDefinition` from every table that `@RegisterTable` registered for the schema ID. It constructs the `Schema`, which the database module provisions, and binds each table class to it (see [Open a table from its class](#open-a-table-from-its-class)). Then it inserts the fixtures of the tables that are empty. -Call it in the module that declares the tables: the binding then follows that module's database, whichever module later uses the classes. +Call it in the module that declares the tables. The binding then follows that module's database, whichever module later uses the classes. ```typescript import { RegisterSchema } from "@antelopejs/interface-database-decorators"; @@ -330,13 +346,19 @@ await RegisterSchema("myapp"); | ---------- | -------- | ------------------------------------------------------------------ | | `schemaId` | `string` | Schema id matching the `@RegisterTable(_, schemaId)` declarations. | -`RegisterSchema` returns `Promise`. +`RegisterSchema` returns `Promise`. It throws `No tables registered for schema ''` when no class registers into the schema. -## Open a Table from its Class +## Open a table from its class `Table.table()` returns the AQL table a class describes, in the schema `RegisterSchema` bound it to: -```typescript +```typescript fragment +import { + RegisterSchema, + RegisterTable, + Table, +} from "@antelopejs/interface-database-decorators"; + @RegisterTable("users", "myapp") class User extends Table { declare name: string; @@ -349,4 +371,4 @@ User.table("tenant-1"); // instance "tenant-1" (CROSS_INSTANCE is accepted too) User.table(db); // db.table("users") on a SchemaInstance you already hold ``` -When given a `SchemaInstance`, `table()` takes the table from it as is: pass an instance of the class's own schema. A class that no `RegisterSchema` call bound throws, naming the class. +Given a `SchemaInstance`, `table()` takes the table from it as is, so pass an instance of the class's own schema. A class without `@RegisterTable` throws ` has no table name`. A class that no `RegisterSchema` call bound, and whose schema name has no schema, throws ` is not bound to a schema`. diff --git a/docs/3.table-modifiers.md b/docs/3.table-modifiers.md index 78a943a..d6e3aeb 100644 --- a/docs/3.table-modifiers.md +++ b/docs/3.table-modifiers.md @@ -1,14 +1,18 @@ -# Table Modifiers +--- +title: Table modifiers +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. +mode: reference +--- -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. +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. -## Built-in Modifiers +## Built-in modifiers -### EncryptionModifier +### `EncryptionModifier` -The `EncryptionModifier` encrypts field values before storing them and decrypts them on retrieval. Both operations happen transparently. +The `EncryptionModifier` encrypts a field's value when the value is assigned and decrypts it when the field is read. -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. +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. ```ts docs-check import { @@ -41,13 +45,13 @@ class UserCredentials extends Table.with(EncryptionModifier) { } ``` -#### How It Works +#### How it works -- Data is encrypted using the specified algorithm before database insertion. -- A random initialization vector (IV) is generated for each write. -- For authenticated encryption algorithms (e.g., `aes-256-gcm`), an auth tag is stored alongside the ciphertext. -- On retrieval, the data is automatically decrypted. -- Date objects are restored as `Date` instances, but the encoder uses `toUTCString()`, which drops milliseconds. Encrypted dates round-trip only to whole-second precision. +- An assignment encodes the value as JSON and encrypts it with the configured algorithm. The row stores `[ciphertext, iv, authTag]`, each in base64. +- Each write generates a random initialization vector (IV). +- An authenticated algorithm, such as `aes-256-gcm`, also stores its auth tag. Other algorithms store no tag. +- A read decrypts the value. +- 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. #### Options @@ -57,9 +61,9 @@ class UserCredentials extends Table.with(EncryptionModifier) { | `algorithm` | `string` | `"aes-256-gcm"` | Encryption algorithm. | | `ivSize` | `number` | `16` | Initialization vector size in bytes. | -### HashModifier +### `HashModifier` -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. +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. ```typescript import { @@ -83,39 +87,43 @@ class User extends Table.with(HashModifier) { } ``` -#### How It Works +#### How it works -- Each write generates a random salt and stores `$scrypt$ln=15,r=8,p=3$$` in the field. The string holds the algorithm, the cost, the salt and the hash, so the cost can change later. -- Assigning the stored hash back to the field, or calling `lock` on a loaded row, keeps the hash as it is. -- `testHash` and `testHashAsync` read the cost and the salt from the stored string, hash the candidate and compare in constant time. -- A test spends one scrypt run whatever the row holds, so the time of a refusal does not tell whether a hash exists. -- 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. +- The field hashes the value encoded as JSON, so `"secret"` is hashed as the text `"\"secret\""`. +- Each write draws a random 16-byte salt and stores `$scrypt$ln=15,r=8,p=3$$`. 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. +- 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). +- 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. +- `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. +- 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. +- 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. #### Options -| Option | Type | Default | Description | -| ----------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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. | +| Option | Type | Default | Description | +| ----------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. | -#### Mixin Methods +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. + +#### Mixin methods The `HashModifier` mixin adds these methods to table instances: -```typescript -testHash(field: string, value: unknown): boolean -testHashAsync(field: string, value: unknown): Promise -isHashOutdated(field: string): boolean +```typescript fragment +testHash(this: This, field: F, value: This[F]): unknown +testHashAsync(this: This, field: F, value: This[F]): Promise +isHashOutdated(this: This, field: keyof This): boolean ``` -- `testHashAsync` returns whether `value` matches the stored hash. It runs scrypt on the thread pool, so use it on a server. -- `testHash` returns the same result and blocks the event loop while scrypt runs. -- `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. +- `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 is not hashed` for a field without `@Hashed`. +- `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. +- `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 is not hashed` for a field without `@Hashed`. -#### Upgrade Stored Hashes +#### Upgrade stored hashes -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: +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: -```typescript +```typescript fragment if (await user.testHashAsync("password", password)) { if (user.isHashOutdated("password")) { user.password = password; @@ -124,13 +132,13 @@ if (await user.testHashAsync("password", password)) { } ``` -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. +`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. -`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. +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. -### LocalizationModifier +### `LocalizationModifier` -The `LocalizationModifier` stores multiple language versions of a field value. Access a specific locale by calling `localize()` on the table instance. +The `LocalizationModifier` stores one value of a field per locale. To read or write a locale, call `localize()` on the table instance. ```typescript import { @@ -154,9 +162,11 @@ class Product extends Table.with(LocalizationModifier) { } ``` +`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. + #### Usage -```typescript +```typescript fragment const product = new Product(); product._id = "prod-123"; product.price = 99.99; @@ -170,30 +180,29 @@ product.localize("es").name = "Auriculares Premium"; const spanish = product.localize("es"); console.log(spanish.name); // "Auriculares Premium" -// Fallback to default locale when translation is missing +// Fall back to the default locale when a translation is missing const german = product.localize("de"); console.log(german.name); // "Premium Headphones" (fallback) ``` -#### Wildcard Key +#### Wildcard key -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: +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: -```typescript +```typescript fragment // Get all translations -const translations = product.localize("*") as unknown as { - name: Record; -}; -const allNames = translations.name; +const allNames: unknown = product.localize("*").name; // { en: "Premium Headphones", fr: "Casque Premium", es: "Auriculares Premium" } // Set all translations -translations.name = { - en: "Premium Headphones", - fr: "Casque Premium", - es: "Auriculares Premium", - de: "Premium-Kopfhoerer", -}; +Object.assign(product.localize("*"), { + name: { + en: "Premium Headphones", + fr: "Casque Premium", + es: "Auriculares Premium", + de: "Premium-Kopfhoerer", + }, +}); ``` #### Options @@ -202,23 +211,23 @@ translations.name = { | ---------------- | -------- | --------------------------------------------------------------------- | | `fallbackLocale` | `string` | Locale to use when the requested locale has no value or an empty one. | -#### Mixin Method +#### Mixin method The `LocalizationModifier` mixin adds a `localize` method: -```typescript -localize(locale: string, fields?: Array): this +```typescript fragment +localize(this: This, locale: string, fields?: Array): This ``` -`localize()` changes the active locale and returns the same object, not a locale-specific copy. +`localize()` changes the active locale and returns the same object, not a copy for that locale. - **References share state.** `spanish`, `german`, and `product` above refer to one object. A later `localize()` call changes what all three references read. -- **Fields can be restricted.** When you supply `fields`, only those fields are unlocked for the selected locale. -- **Copy values before switching.** Save the values you need before changing locales, and do not share one instance between concurrent locale-specific operations. +- **Fields can be restricted.** When you supply `fields`, only those fields switch to the selected locale. +- **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. -### AutoDateModifier +### `AutoDateModifier` -The `AutoDateModifier` automatically populates timestamp fields during insert and update operations. Use the `CreationTime` and `UpdateTime` decorators to mark fields. +The `AutoDateModifier` sets timestamp fields on insert and update. Mark the fields with the `CreationTime` and `UpdateTime` decorators. ```typescript import { @@ -238,15 +247,15 @@ class Article extends Table { } ``` -#### How It Works +#### How it works -- On insert, both `CreationTime` and `UpdateTime` fields are set to the current date. -- On update, `UpdateTime` fields are refreshed while `CreationTime` fields are removed from the payload so the original creation date is preserved. -- This is an event-only modifier: it does not transform stored values and does not require adding a mixin through `Table.with()`. +- On insert, both `CreationTime` and `UpdateTime` fields get the current date. +- On update, `UpdateTime` fields get the current date, and `CreationTime` fields are removed from the payload, so the stored creation date stays. +- The modifier only handles events. It does not transform stored values and has no mixin, so the table class needs no `Table.with()`. -## Combine Multiple Modifiers +## Combine multiple modifiers -Pass multiple modifier classes to `Table.with()`: +Pass several modifier classes to `Table.with()`: ```typescript import { @@ -287,11 +296,11 @@ class UserProfile extends Table.with( } ``` -## Create Custom Modifiers +## Create custom modifiers -### OneWayModifier +### `OneWayModifier` -A `OneWayModifier` transforms data in one direction (when writing to the database). Use it for irreversible transformations like hashing. +A `OneWayModifier` transforms data in one direction, when a value is written. Use it for a transformation that cannot be reversed, such as hashing. ```typescript import { @@ -332,18 +341,18 @@ const PrefixHashed = MakePropertyDecorator( ); ``` -The generic parameters for `OneWayModifier` are: +The generic parameters of `OneWayModifier` are: -| Parameter | Description | -| ------------ | ---------------------------------------------------- | -| `LockedType` | The type stored in the database after transformation | -| `Args` | Additional arguments for `lock` and `test` | -| `Meta` | Per-field metadata stored alongside the value | -| `Options` | Configuration passed via the decorator | +| Parameter | Description | +| ------------ | -------------------------------------------------------------------------------------------------------- | +| `LockedType` | The type stored in the database after the transformation. | +| `Args` | Additional arguments for `lock` and `test`. Defaults to `[]`. | +| `Meta` | The modifier's state for a row, kept in `_internal.meta.` and shared by its fields. | +| `Options` | The configuration that the decorator passes to `attachModifier`. | -### TwoWayModifier +### `TwoWayModifier` -A `TwoWayModifier` extends `OneWayModifier` with an `unlock` method for reversible transformations like encryption: +A `TwoWayModifier` extends `OneWayModifier` with an `unlock` method, for a transformation that can be reversed, such as encryption: ```ts docs-check import { @@ -355,7 +364,7 @@ import { MakePropertyDecorator } from "@antelopejs/interface-core/decorators"; import type { ValueProxy } from "@antelopejs/interface-database"; class Base64Modifier extends TwoWayModifier< - string | undefined, + unknown, [], {}, { prefix?: string } @@ -363,23 +372,23 @@ class Base64Modifier extends TwoWayModifier< public readonly autolock = true; public readonly autounlock = true; - public override lock(_locked: string | undefined, value: unknown) { + public override lock(_locked: unknown, value: unknown) { if (value === undefined) return undefined; const prefix = this.options.prefix || "b64-"; return `${prefix}${Buffer.from(JSON.stringify(value)).toString("base64")}`; } - public override unlock(locked: string | undefined): unknown { - if (locked === undefined) return undefined; + public override unlock(locked: unknown): unknown { + if (typeof locked !== "string") return undefined; const prefix = this.options.prefix || "b64-"; const encoded = locked.substring(prefix.length); return JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); } public override unlockrequest( - data: ValueProxy, + data: ValueProxy, ): ValueProxy { - return data.cast(); + return data; } [MixinSymbol] = class {}; @@ -397,11 +406,11 @@ const Base64Encoded = MakePropertyDecorator( ); ``` -Base64 is encoding, not encryption. This example's `unlockrequest()` leaves the encoded value unchanged in database expressions; only instance reads decode it. +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`. -### ContainerModifier +### `ContainerModifier` -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: +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: ```typescript import { ContainerModifier } from "@antelopejs/interface-database-decorators"; @@ -412,18 +421,18 @@ class MyContainerModifier extends ContainerModifier<{}, { fallback?: string }> { } ``` -## Modifier Events +## Modifier events -Modifiers can hook into the data lifecycle by implementing event handler methods. These methods are called during data conversion and database operations: +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: -| Event | Triggered When | -| --------------- | ------------------------------------------ | -| `fromPlainData` | After converting plain data to an instance | -| `toPlainData` | After converting an instance to plain data | -| `fromDatabase` | After reading from the database | -| `toDatabase` | Before writing to the database | -| `insert` | Before inserting a new record | -| `update` | Before updating an existing record | +| Event | Triggered when | +| --------------- | --------------------------------------------------------------------- | +| `fromPlainData` | After plain data is converted to an instance. | +| `toPlainData` | After an instance is converted to plain data. | +| `fromDatabase` | After a stored record is converted to an instance, as reads do. | +| `toDatabase` | When an instance is converted to the record to store, before a write. | +| `insert` | Before a model or a fixture inserts a new record. | +| `update` | Before a model updates an existing record. | ```typescript import { @@ -432,15 +441,15 @@ import { } from "@antelopejs/interface-database-decorators"; class TimestampModifier extends Modifier { - public insert(instance: any, field: string) { - if (field === "createdAt" && !instance[field]) { - instance[field] = new Date(); + public insert(object: Partial>, field: F) { + if (field === "createdAt" && !object[field]) { + object[field] = new Date(); } } - public update(instance: any, field: string) { + public update(object: Partial>, field: F) { if (field === "updatedAt") { - instance[field] = new Date(); + object[field] = new Date(); } } @@ -448,4 +457,4 @@ class TimestampModifier extends Modifier { } ``` -Event-only modifiers (without `lock`/`unlock`) are valid and useful for side effects like logging, validation, or auto-populating fields. +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. diff --git a/docs/4.data-models.md b/docs/4.data-models.md index 67302a9..fbe59a3 100644 --- a/docs/4.data-models.md +++ b/docs/4.data-models.md @@ -1,12 +1,16 @@ -# Data Models +--- +title: Data models +description: Reference for BasicDataModel, its CRUD methods and return values, the data conversions, validation, and GetModel. +mode: reference +--- -Data models provide a high-level interface for interacting with database tables. They handle conversion between database records and TypeScript class instances, and expose methods for common CRUD operations. +A data model reads and writes one table. It converts between stored records and instances of the table class, and has methods for the common CRUD operations. -## BasicDataModel +## `BasicDataModel` -The `BasicDataModel` function creates a model class for a specific table. It wraps the underlying AQL table with methods that automatically apply modifier transformations. +The `BasicDataModel` function creates a model class for a table. The class wraps the AQL table with methods that apply the modifier transformations. -```typescript +```typescript fragment import { BasicDataModel } from "@antelopejs/interface-database-decorators"; import { User } from "./user.table"; @@ -15,27 +19,28 @@ const UserModel = BasicDataModel(User, "users"); ### Parameters -| Parameter | Type | Description | -| ----------- | ------------------ | --------------------------------------------------------------------- | -| `dataType` | `Constructible` | The Table class representing your table | -| `tableName` | `string` | The table name in the database (optional if `@RegisterTable` is used) | +| Parameter | Type | Description | +| ----------- | ------------------ | ----------------------------------------------------------------------- | +| `dataType` | `Constructible` | The table class. | +| `tableName` | `string` | The table name in the database. Optional when `@RegisterTable` sets it. | -When the table class has been decorated with `@RegisterTable`, the `tableName` parameter can be omitted. +When the table class has `@RegisterTable`, you can omit `tableName`. Without either, `BasicDataModel` throws `tableName must be provided or set via @RegisterTable`. -## Instantiate a Model +## Instantiate a model -A model instance requires a `SchemaInstance` from the database interface: +A model instance needs a `SchemaInstance` from the database interface: -```typescript +```typescript fragment import { Schema } from "@antelopejs/interface-database"; -const schema = Schema.get("myapp")!; +const schema = Schema.get("myapp"); +if (!schema) throw new Error("Schema myapp is not registered"); const db = schema.instance(); const userModel = new UserModel(db); ``` -## CRUD Operations +## Read and write rows For a model created with `BasicDataModel(User)`, the return values are: @@ -47,11 +52,11 @@ For a model created with `BasicDataModel(User)`, the return values are: | `update(...)` | `Promise` | Number of modified documents. | | `delete(id)` | `Promise` | Number of deleted documents. | -Use `model.table` to compose AQL queries before execution. The static `fromDatabase()` conversion can return `undefined`; `toDatabase()` returns a plain `Record`, not a saved record or a write result. +Use `model.table` to compose an AQL query before you run it. The static `fromDatabase()` conversion returns `undefined` when the record is `null` or `undefined`, as `table.get` gives for a missing ID. The static `toDatabase()` returns a `StoredRow`, the record to store, not a saved record or a write result. ### Read -```typescript +```typescript fragment // Get by primary key const user = await userModel.get("user-123"); @@ -64,7 +69,7 @@ const allUsers = await userModel.getAll(); ### Create -```typescript +```typescript fragment // Insert a single document const ids = await userModel.insert({ email: "alice@example.com", @@ -80,7 +85,7 @@ const batchIds = await userModel.insert([ ### Update -```typescript +```typescript fragment // Update by ID await userModel.update("user-123", { name: "Updated Name" }); @@ -88,17 +93,17 @@ await userModel.update("user-123", { name: "Updated Name" }); await userModel.update({ _id: "user-123", name: "Updated Name" }); ``` -> **Important:** When calling `update` without a separate ID argument, the object must include the primary key field. +When you call `update` without a separate ID argument, the object must include the primary key. Its type requires `_id`, and a call without it throws `Missing primary key in object update.` ### Delete -```typescript +```typescript fragment await userModel.delete("user-123"); ``` -## Data Conversion +## Data conversion -Data models include static methods for converting between different data representations: +A model class has static methods that convert between the forms of a row: | Starting value | Conversion | Result | Persists data? | | ----------------------- | --------------- | ------------------------------------------------- | -------------- | @@ -106,15 +111,15 @@ Data models include static methods for converting between different data represe | Raw database record | `fromDatabase` | Table instance with read modifier events applied | No | | Table instance | `toDatabase` | Plain storage representation | No | -Use `insert` or `update` to perform a write; conversion alone does not save anything. Reads through `get`, `getBy`, and `getAll` apply `fromDatabase` for you. A custom query through `model.table` returns AQL results, so apply conversion explicitly when you need model instances. +Use `insert` or `update` to write. A conversion alone saves nothing. Reads through `get`, `getBy`, and `getAll` apply `fromDatabase` for you. A custom query through `model.table` returns AQL results, so apply conversion explicitly when you need model instances. -![Model writes convert input, trigger insert or update events, convert to storage, then execute AQL. Model reads convert fetched records; direct model.table queries bypass model conversion.](./assets/model-conversion.svg){width="480"} +![Model writes convert input, trigger insert or update events, convert to storage, then execute AQL. Model reads convert fetched records. Direct model.table queries bypass model conversion.](./assets/model-conversion.svg "illustration"){width="480"} ### `fromPlainData(obj)` -Convert a plain JavaScript object into a Table class instance. Modifier transformations (like encryption and hashing) are applied during this conversion. +Converts a plain JavaScript object into an instance of the table class. Each key is assigned, so modifiers such as encryption and hashing transform the values during this conversion. An instance of the table class is returned as it is. -```typescript +```typescript fragment const user = UserModel.fromPlainData({ _id: "user-123", email: "user@example.com", @@ -122,56 +127,62 @@ const user = UserModel.fromPlainData({ }); ``` +The object is a `PlainData`. Every field is optional and may be partial, and a field under a container modifier may hold its value per key, as a localized field holds one text per locale. `toPlainData` gives the reverse form, a `PlainRow`. + ### `fromDatabase(obj)` -Convert a raw database record into a Table class instance. This attaches the class prototype and triggers the `fromDatabase` modifier event. +Converts a stored record into an instance of the table class. It copies the record, sets the class prototype on the copy, and triggers the `fromDatabase` modifier event. It returns `undefined` when the record is `null` or `undefined`, as `table.get` gives for a missing ID. An empty object still gives an instance. -```typescript +```typescript fragment const user = UserModel.fromDatabase(rawDbRecord); ``` ### `toDatabase(obj)` -Convert a Table class instance into a plain object suitable for database storage. Modifier events like `toDatabase` are triggered. +Converts an instance of the table class, or plain data, into the record to store, and triggers the `toDatabase` modifier event. The result is a `StoredRow`. It holds the fields that are set, has no prototype, and keeps its modified fields locked in `_internal` instead of at the top level. -```typescript +```typescript fragment const dbReady = UserModel.toDatabase(userInstance); ``` ## Validation -`Model.validate(obj)` runs every io-ts codec attached via `@Field` against the matching property of `obj`. String-token fields and properties without `@Field` are skipped. +`Model.validate(obj)` runs every io-ts codec that `@Field` attached against the matching property of `obj`. It skips string-token fields and properties without `@Field`. A failure's `message` is the first message the codec reports, or `invalid ` when the codec reports none, as io-ts's built-in codecs do. -```typescript +```typescript fragment const result = UserModel.validate({ name: "Alice", age: 30 }); if (!result.ok) { - console.error(result.errors); // [{ field: "age", message: "not number" }, ...] + console.error(result.errors); // [{ field: "age", message: "invalid age" }] } ``` -Pass `{ validate: true }` to `insert` or `update` to gate the write on validation. On failure the call throws an `Error` whose `errors` property holds the per-field details; on success the original object passes through unchanged. +Pass `{ validate: true }` to `insert` or `update` to validate before the write. On failure the call throws an `Error` with the message `validation failed: (), ...` and an `errors` property that holds the details per field. On success the write goes on with the object unchanged. -```typescript +```typescript fragment await userModel.insert({ name: "Alice", age: 30 }, { validate: true }); await userModel.update("user-123", { name: "Bob" }, { validate: true }); ``` -`insert` validates every annotated codec field. `update` is partial: only fields present on the patch object are checked, so omitting fields you don't intend to modify is fine. Use `Model.validate(obj, { partial: true })` for the same patch semantics outside of `update`. +`insert` validates every codec field of each row. `update` validates only the fields present on the patch object, so you can leave out the fields you do not change. For the same patch check outside `update`, use `Model.validate(obj, { partial: true })`. -## Extend BasicDataModel +## Extend `BasicDataModel` -Extend the generated model class to add custom query methods: +Extend the generated model class to add custom query methods. -The `register` method below checks for an existing email to provide early feedback. It does not guarantee uniqueness under concurrent writes; enforce that constraint in the database where supported and handle conflicts. +The `register` method below checks for an existing email to report a duplicate early. It does not guarantee uniqueness under concurrent writes. Enforce that constraint in the database with a unique index where the database module supports it, and handle the conflict. `findActive` converts each row with `fromDatabase`, which returns `undefined` only when the record is `null` or `undefined`, and the filter drops a missing row. -```typescript +```typescript fragment const UserModelBase = BasicDataModel(User, "users"); class CustomUserModel extends UserModelBase { async findActive() { return this.table .filter((u) => u.key("status").eq("active")) - .then((rows) => rows.map(CustomUserModel.fromDatabase) as User[]); + .then((rows) => + rows + .map((row) => CustomUserModel.fromDatabase(row)) + .filter((user) => user !== undefined), + ); } async register(email: string, name: string) { @@ -184,24 +195,24 @@ class CustomUserModel extends UserModelBase { } ``` -## GetModel +## `GetModel` -The `GetModel` function retrieves or creates a cached model instance for a given schema: +The `GetModel` function returns the cached model instance for a model class and an instance ID, and creates it on the first call: -```typescript +```typescript fragment import { GetModel } from "@antelopejs/interface-database-decorators"; const userModel = GetModel(UserModel, "instance-id"); ``` -Model instances are cached by class and instance ID, so repeated calls with the same arguments return the same instance. A model built by `BasicDataModel` uses the schema its table class was bound to by `RegisterSchema`, so it stays on the declaring module's database even when another module calls `GetModel`; an unbound table class throws. A custom model class without a `tableClass` is resolved from its `schemaName` with `Schema.get`. +The cache key is the class and the instance ID, so repeated calls with the same arguments return the same instance. Without an instance ID, the model uses the default instance. A model built by `BasicDataModel` uses the schema that `RegisterSchema` bound its table class to, so it stays on the declaring module's database even when another module calls `GetModel`. An unbound table class whose schema name has no schema throws ` is not bound to a schema`. A custom model class without a `tableClass` gets its schema from its `schemaName` through `Schema.get`. -## Modifier Integration +## Modifier integration -Data models automatically apply all modifier transformations: +Data models apply every modifier transformation: -- **On insert** - `fromPlainData` conversion is applied, the `insert` event fires, and the result is converted with `toDatabase` before writing. -- **On update** - The same conversion pipeline applies with the `update` event. -- **On read** - Results from `get`, `getBy`, and `getAll` are converted with `fromDatabase`. +- On insert, the model converts the data with `fromPlainData`, fires the `insert` event, and converts the result with `toDatabase` before the write. +- On update, the model runs the same conversions with the `update` event. It leaves out an empty `_internal`, so a patch keeps what the row stores there. +- On read, the model converts the results of `get`, `getBy`, and `getAll` with `fromDatabase`. -This means encrypted fields are encrypted before storage and decrypted on retrieval, hashed fields are hashed on write, and localized fields follow their configured locale behavior -- all transparently. +As a result, encrypted fields are encrypted before storage and decrypted on read, hashed fields are hashed on write, and localized fields follow their locale settings. diff --git a/docs/5.parameter-decoration.md b/docs/5.parameter-decoration.md index f6400ef..b16c668 100644 --- a/docs/5.parameter-decoration.md +++ b/docs/5.parameter-decoration.md @@ -1,16 +1,20 @@ -# Parameter Decoration +--- +title: Parameter decoration +description: Inject a data model into an API controller method or property with the Model decorator, with a fixed or a per-request instance ID. +mode: how-to +--- -The Database Decorators package provides the `Model` decorator for injecting data model instances into API controller methods. This decorator integrates with the `@antelopejs/interface-api` package's parameter provider system. +The `Model` decorator injects a data model instance into an API controller method. It registers a parameter provider of `@antelopejs/interface-api` through `SetParameterProvider`. -## The Model Decorator +## The `Model` decorator -The `Model` decorator injects a cached model instance. It can be used as either a parameter decorator or a property decorator. +The `Model` decorator injects the cached model instance that `GetModel` returns. It works as a parameter decorator and as a property decorator. -### Static Instance ID +### Pass a fixed instance -Pass a fixed instance ID to bind the model to a specific schema instance: +To bind the model to one schema instance, pass a fixed instance ID: -```typescript +```typescript fragment import { Controller, Get } from "@antelopejs/interface-api"; import { Model } from "@antelopejs/interface-database-decorators"; import { UserModel } from "./user.model"; @@ -25,13 +29,13 @@ class UsersController extends Controller("/users") { } ``` -### Dynamic Instance ID +### Pick the instance per request -Pass a callback function to determine the instance ID at request time. The callback receives the request context: +To pick the instance ID for each request, pass a callback. It receives the request context and returns an instance ID, or `undefined` for the default instance. -Authorize access to the requested tenant before reading or writing through the injected model. The callback selects an instance; a URL parameter alone is not proof that the caller belongs to that tenant. +Authorize access to the requested tenant before you read or write through the injected model. The callback only selects an instance, and a URL parameter alone does not prove that the caller belongs to that tenant. -```typescript +```typescript fragment import { Controller, Get, Parameter } from "@antelopejs/interface-api"; import { Model } from "@antelopejs/interface-database-decorators"; import { UserModel } from "./user.model"; @@ -40,7 +44,7 @@ class MultitenantController extends Controller("/tenants") { @Get("/:tenantId/users") async getTenantUsers( @Parameter("tenantId") tenantId: string, - @Model(UserModel, (ctx) => `tenant-${ctx.params.tenantId}`) + @Model(UserModel, (ctx) => `tenant-${ctx.routeParameters.tenantId}`) userModel: InstanceType, ) { return await userModel.getAll(); @@ -48,11 +52,11 @@ class MultitenantController extends Controller("/tenants") { } ``` -## Property Decoration +## Property decoration -The `Model` decorator also works as a property decorator. The model is initialized when the controller handles a request: +The `Model` decorator also works as a property decorator. The property gets its model when the controller handles a request. Outside a request handler, the property is unset, although the `!` declaration types it as always present: -```typescript +```typescript fragment import { Controller, Get, Parameter } from "@antelopejs/interface-api"; import { Model } from "@antelopejs/interface-database-decorators"; import { UserModel } from "./models"; @@ -73,11 +77,11 @@ class UsersController extends Controller("/users") { } ``` -## Model Caching +## Model caching -The `Model` decorator uses `GetModel` internally, which caches model instances by class and instance ID. Multiple decorators referencing the same model class and instance ID share a single instance: +The `Model` decorator calls `GetModel`, which caches model instances by class and instance ID. Decorators that name the same model class and instance ID share one instance: -```typescript +```typescript fragment import { GetModel } from "@antelopejs/interface-database-decorators"; // These return the same instance @@ -88,13 +92,13 @@ const model2 = GetModel(UserModel, "main"); const model3 = GetModel(UserModel, "other"); ``` -## Combine with API Decorators +## Combine with API decorators -Model decorators compose naturally with other API decorators for building complete CRUD controllers: +`@Model` combines with the other API decorators in a CRUD controller. -This example demonstrates injection, not a complete public API policy. Before exposing writes, reject malformed or non-object JSON, allow only permitted fields, validate their values, and authorize the operation. `JSON.parse` and `@Model` do not provide these checks; string-token `@Field` declarations do not validate input either. +This example shows injection, not a complete public API policy. Before you expose writes, reject malformed or non-object JSON, allow only permitted fields, validate their values, and authorize the operation. `JSON.parse` and `@Model` do none of these checks, and string-token `@Field` declarations do not validate input either. -```typescript +```typescript fragment import { Controller, Get, @@ -150,9 +154,9 @@ class UsersController extends Controller("/users") { } ``` -## Custom Parameter Providers +## Custom parameter providers -For more complex injection logic, create custom parameter providers using the core API: +For other injection logic, write your own decorator that registers a parameter provider: ```typescript import { SetParameterProvider } from "@antelopejs/interface-api"; diff --git a/docs/tsconfig.json b/docs/tsconfig.json index d7f31e7..035ac0b 100644 --- a/docs/tsconfig.json +++ b/docs/tsconfig.json @@ -2,6 +2,9 @@ "extends": "../tsconfig.json", "compilerOptions": { "rootDir": "..", + "module": "esnext", + "moduleResolution": "bundler", + "declaration": false, "noEmit": true, "composite": false, "incremental": false diff --git a/guardrails.ratchet.json b/guardrails.ratchet.json deleted file mode 100644 index 711bfd2..0000000 --- a/guardrails.ratchet.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "pack": "0.2.17", - "rules": { - "antelopejs/no-inherited-key-lookup": 9, - "antelopejs/no-presence-only-type-guard": 2, - "anti-slop/no-chained-type-assertions": 1, - "anti-slop/no-known-value-widening": 2, - "anti-slop/no-runtime-typeof": 1, - "anti-slop/no-unknown-parameters": 8, - "anti-slop/no-unknown-returns": 1, - "anti-slop/no-unsafe-dictionary-type": 18, - "anti-slop/require-safety-comment-for-type-assertion": 15, - "typescript/no-unnecessary-type-parameters": 8, - "typescript/no-unsafe-argument": 18, - "typescript/no-unsafe-assignment": 16, - "typescript/no-unsafe-return": 17 - }, - "oversizeFiles": {} -} diff --git a/guardrails.suppressions.json b/guardrails.suppressions.json index d45133d..399ae6d 100644 --- a/guardrails.suppressions.json +++ b/guardrails.suppressions.json @@ -1,7 +1,14 @@ { - "rules": {}, + "rules": { + "anti-slop/no-unknown-parameters": 8, + "anti-slop/no-unknown-returns": 1, + "anti-slop/no-unsafe-dictionary-type": 11, + "typescript/no-unnecessary-type-parameters": 4, + "typescript/no-unsafe-assignment": 2, + "typescript/no-unsafe-return": 3 + }, "tsExpectError": 0, "tsIgnore": 0, - "safety": 5, + "safety": 9, "config": {} } diff --git a/lefthook.yml b/lefthook.yml index ff792b5..779c267 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -8,9 +8,6 @@ pre-commit: run: pnpm exec oxlint --no-error-on-unmatched-pattern {staged_files} antelopejs-size: run: pnpm exec antelopejs-check-size --staged {staged_files} - antelopejs-ratchet: - glob: "*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue}" - run: pnpm exec antelopejs-ratchet --staged {staged_files} antelopejs-suppressions: glob: "*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue}" run: pnpm exec antelopejs-suppressions --staged diff --git a/package.json b/package.json index 5fd9910..2f7d103 100644 --- a/package.json +++ b/package.json @@ -131,9 +131,7 @@ "prepare": "if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && command -v lefthook >/dev/null 2>&1; then lefthook install; fi", "checks": "antelopejs-checks", "size": "antelopejs-check-size", - "ratchet": "antelopejs-ratchet", "sync": "antelopejs-sync", - "ratchet:update": "antelopejs-ratchet --update", "sync:check": "antelopejs-sync --check", "typecheck": "tsc --noEmit", "suppressions": "antelopejs-suppressions", @@ -144,10 +142,10 @@ "reflect-metadata": "^0.2.2" }, "devDependencies": { - "@antelopejs/core": "^1.16.0", + "@antelopejs/core": "^1.20.0", "@antelopejs/interface-api": "^0.0.15", - "@antelopejs/interface-core": "^0.2.4", - "@antelopejs/interface-database": "^0.1.11", + "@antelopejs/interface-core": "^0.4.0", + "@antelopejs/interface-database": "^0.3.0", "@antelopejs/tooling-configs": "^0.2.17", "@types/chai": "^4.3.20", "@types/mocha": "^10.0.10", @@ -172,12 +170,15 @@ "@antelopejs/interface-core": ">=0.2.4 <1.0.0", "@antelopejs/interface-database": ">=0.1.11 <1.0.0" }, - "packageManager": "pnpm@10.6.5", + "packageManager": "pnpm@10.33.0", "antelopeJs": { "standalone": true, "test": "src/antelope.test.ts", "skills": [ "./skills" - ] + ], + "docs": { + "standard": true + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 329068e..7a1185c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,17 +16,17 @@ importers: version: 0.2.2 devDependencies: '@antelopejs/core': - specifier: ^1.16.0 - version: 1.16.0 + specifier: ^1.20.0 + version: 1.20.0 '@antelopejs/interface-api': specifier: ^0.0.15 - version: 0.0.15(@antelopejs/interface-core@0.2.4) + version: 0.0.15(@antelopejs/interface-core@0.4.0) '@antelopejs/interface-core': - specifier: ^0.2.4 - version: 0.2.4 + specifier: ^0.4.0 + version: 0.4.0 '@antelopejs/interface-database': - specifier: ^0.1.11 - version: 0.1.11(@antelopejs/interface-core@0.2.4) + specifier: ^0.3.0 + version: 0.3.0(@antelopejs/interface-core@0.4.0) '@antelopejs/tooling-configs': specifier: ^0.2.17 version: 0.2.17(eslint-plugin-perfectionist@5.11.0(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3))(knip@6.34.0)(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2001)) @@ -84,9 +84,9 @@ importers: packages: - '@antelopejs/core@1.16.0': - resolution: {integrity: sha512-dj1R8xPgLzkTksfenPNh3kX1fffRnWktielk1yad/4/1wTEMhkPSx1WQ/4QI98d95L6Th0EMGTV/U4o84p/2yw==} - engines: {node: '>=20.19'} + '@antelopejs/core@1.20.0': + resolution: {integrity: sha512-7clqf/n8aUi4iCwMG0G2dWLQCwzy9ZxTLHLmOIEtfObw5wuN06s0+n57Zzjr3wbIyR2wD3Q4AeorqJ94QidIfg==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true '@antelopejs/interface-api@0.0.15': @@ -94,11 +94,11 @@ packages: peerDependencies: '@antelopejs/interface-core': '>=0.2.4 <1.0.0' - '@antelopejs/interface-core@0.2.4': - resolution: {integrity: sha512-2qF6/KwahCQvvNuC/piUvPWD4wq2Npmco941HMg2/bMBoJKbhMH16WcXXRvPYznNSkU0U+6vqK6KE+2N5NojSA==} + '@antelopejs/interface-core@0.4.0': + resolution: {integrity: sha512-IPX6dtt3KHknclN4mLOYeRYL2Odz1Pts+dtaZul62NghsBUX/0OFHD4Qhoaf+Beva4hjInbaF918ajr4Z5HVWQ==} - '@antelopejs/interface-database@0.1.11': - resolution: {integrity: sha512-uv6mB1Lghmlc9z+oWzntmzD1ZNBAmrj+2+7MItv6zClEolbqw5hfAt8+c3IPq2yLfWDfhpqWme2m0eTwsAm0RQ==} + '@antelopejs/interface-database@0.3.0': + resolution: {integrity: sha512-sPs/EROiuRgFr8r3SGrvO+9b8ZzBNkuwanN3rZbMfLQyQQ42CnULLkLG2JjE9/tRnbvGjgebJlU/VxhGrHBaoA==} peerDependencies: '@antelopejs/interface-core': '>=0.2.4 <1.0.0' @@ -456,48 +456,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-arm64-musl@0.147.0': resolution: {integrity: sha512-gd7VX/FDVOw6mjQcu45iIcp4QkgybgJwh3a0OFG2NxmPCj628mQWD96QGu1kK8+mZF9qK4b/gIEyC63vQoB7+Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': resolution: {integrity: sha512-HnAzcfki7dSUNHf510Q2NmbJlz8Ys7rn8l9l588Pkx0tYe1BHLZnmELIgqizJ4WPhHGSwN8Ce+B/menVxS3odA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': resolution: {integrity: sha512-qlkOL6wT44U+fT5s/+sR6Shx0OdwvQF83JyIPZUxG/ovqZF5/7atOtjH+JPZ5/7ATQLbFBBSmghcy/+2NVB/ew==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-musl@0.147.0': resolution: {integrity: sha512-DlefD7L7sMXs/3hIBH23Egk0phj8kG0SA81dVGOQ3S1ekjOlmTLH2E+F2Thwfh1slKx6aH+lNc5fQYAw0GU7/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-s390x-gnu@0.147.0': resolution: {integrity: sha512-Xqpagk/031IvZ4svrk2FF01YEqM/iN3MJV3SVZadKg/CsGlDCGoREqKHXYnoV5+8SfGe/m6RM1szXFLusTu/Uw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.147.0': resolution: {integrity: sha512-QioQOeUbI4ATUr0S2z88uA3Cds2R3Mm5Ge7U8XNYtlTb2GJF3rWlcj70z0AJhhOlbdm0YgVjqPBldUNbFylDIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-musl@0.147.0': resolution: {integrity: sha512-NXy1tv/OdC+pPTwf9RiCZWPK53V/Xq/2cjSnjOSyKopajdaDqMIkgtDY+jXZemp2e8px5FeWfY2L2LwhKZQovg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.147.0': resolution: {integrity: sha512-GpGWZ6oKz4bjCWW9Mz5pCaGPyk2Aaze6zEoaslIQqpSLtpx5pXj/ap5gUNb5Jn2LIbqWyjkGLX9yv3NMuNcBVQ==} @@ -565,41 +573,49 @@ packages: resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-arm64-musl@11.24.2': resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-gnu@11.24.2': resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-musl@11.24.2': resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} cpu: [x64] os: [linux] + libc: [musl] '@oxc-resolver/binding-openharmony-arm64@11.24.2': resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} @@ -668,48 +684,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.66.0': resolution: {integrity: sha512-tTkF2Dmx4nGAjmBlZb+UtTGqR/EK4ZrW9qBfzte07a9XWqzoGGKzpFFlyNDhQe+Uwql94+ReCTeNbhOXscw1Dg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.66.0': resolution: {integrity: sha512-F3cKHUav4yXOHn6GFnwpBhSYsJOYKKf9eqO/9jlEuqPxNw9zb98E9ZFct79gcg8pibUGkbveEu9WDlmXJpDzKw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.66.0': resolution: {integrity: sha512-K5fDaNZfDyQMYA/3qL21bqyN0X9T15LLwwbFPt2aHc94+ZG7bh0vZEsy2y7NlRnjjHFSwN+Hzg6ldJtbOriH4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.66.0': resolution: {integrity: sha512-44Yc+I+qOmTElRcEhm5hUKIUJEQIOugymz4ua4tB0Wox7tGAfIbjzmXz/HDAtw1Ij6gmBwZlzh4hc9679RhWeA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.66.0': resolution: {integrity: sha512-1e29Eg9hEj2kRBB19M0seIehPbbXHCk35GvImjDvb79rjjYjXCRmtbUNHJcgoktZAMIzXrTbxDBKmTc1V4bg3A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.66.0': resolution: {integrity: sha512-vODY1UQo10gngn0+D4xHKU84F1Twm1LqrzV4SqPXvmQKSd87paehvZ6jqA5wKs6XQrlWul9clYMDVHcoW9CPMA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.66.0': resolution: {integrity: sha512-YDzXx2JsT4+HL4MdkVrYjO55NS5lUKNm8rLC4ZPou8+seu0v0jhecSh+ufoO6+xEa8gccEezMlI2WHJi4ApUgw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/binding-openharmony-arm64@0.66.0': resolution: {integrity: sha512-mJjUYd8lj0+j4JkYyEM+5qKBf1Rnrpgjn/SVYKJhicVDqLz566ooa7Fs8zflPqt+dnZDV7X054rVIQX6ZcQNlQ==} @@ -812,48 +836,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.81.0': resolution: {integrity: sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.81.0': resolution: {integrity: sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.81.0': resolution: {integrity: sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.81.0': resolution: {integrity: sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.81.0': resolution: {integrity: sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.81.0': resolution: {integrity: sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.81.0': resolution: {integrity: sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.81.0': resolution: {integrity: sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==} @@ -2753,9 +2785,9 @@ packages: snapshots: - '@antelopejs/core@1.16.0': + '@antelopejs/core@1.20.0': dependencies: - '@antelopejs/interface-core': 0.2.4 + '@antelopejs/interface-core': 0.4.0 '@clack/prompts': 1.8.1 '@types/proper-lockfile': 4.1.4 commander: 13.1.0 @@ -2768,17 +2800,17 @@ snapshots: semver: 7.7.4 typescript: 5.9.3 - '@antelopejs/interface-api@0.0.15(@antelopejs/interface-core@0.2.4)': + '@antelopejs/interface-api@0.0.15(@antelopejs/interface-core@0.4.0)': dependencies: - '@antelopejs/interface-core': 0.2.4 + '@antelopejs/interface-core': 0.4.0 - '@antelopejs/interface-core@0.2.4': + '@antelopejs/interface-core@0.4.0': dependencies: reflect-metadata: 0.2.2 - '@antelopejs/interface-database@0.1.11(@antelopejs/interface-core@0.2.4)': + '@antelopejs/interface-database@0.3.0(@antelopejs/interface-core@0.4.0)': dependencies: - '@antelopejs/interface-core': 0.2.4 + '@antelopejs/interface-core': 0.4.0 '@antelopejs/tooling-configs@0.2.17(eslint-plugin-perfectionist@5.11.0(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3))(knip@6.34.0)(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2001))': dependencies: diff --git a/skills/database-decorators-interface/SKILL.md b/skills/database-decorators-interface/SKILL.md index 7c269db..28ccc14 100644 --- a/skills/database-decorators-interface/SKILL.md +++ b/skills/database-decorators-interface/SKILL.md @@ -134,7 +134,8 @@ class Chef extends Table.with( - `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 `{ partial: true }` semantics. -- `update(obj)` without an explicit id asserts that the object carries the primary key (`_id` by default). +- `update(obj)` without an explicit id takes an object typed with the primary key `_id`, and asserts at run time that it carries it. +- A row has three forms. `fromPlainData` reads a `PlainData` (every field optional, a localized field possibly one text per locale), `toPlainData` gives a `PlainRow`, and `toDatabase` gives a `StoredRow`, its modified fields locked in `_internal`. `fromDatabase` takes the row the database answered, unchecked. - Indexes are scoped to the schema instance. Add `@Index({ crossInstance: true })` only for indexes also queried across all instances (`CROSS_INSTANCE`); it never changes results, only speed. On a group, one member setting it applies it to the whole group. diff --git a/src/antelope.test.ts b/src/antelope.test.ts index 6ab9e31..3aafb44 100644 --- a/src/antelope.test.ts +++ b/src/antelope.test.ts @@ -21,7 +21,7 @@ export default defineConfig({ source: { type: "package", package: "@antelopejs/api", - version: "1.3.0", + version: "1.6.0", }, config: { servers: [{ protocol: "http", host: "127.0.0.1", port: 5010 }], diff --git a/src/common.ts b/src/common.ts index 870f0b8..2cdd7a1 100644 --- a/src/common.ts +++ b/src/common.ts @@ -1,6 +1,8 @@ import type { Schema } from "@antelopejs/interface-database"; import type { FieldType } from "@antelopejs/interface-database/schema"; +import { isObjectLike } from "./modifiers/internal/kinds"; + export type Constructible = new ( ...args: A ) => T; @@ -15,16 +17,53 @@ export type DeepPartial = { : T[K]; }; -export type DatumGeneratorOutput = - | Record - | Array> +/** + * A table row as `toPlainData` gives it: the fields of `T` that are set, by + * name, without the row's prototype. + */ +export type PlainRow = { [K in Extract]?: T[K] }; + +/** + * A table row as `fromPlainData` reads it: every field is optional and may be + * partial, and a field under a container modifier, such as a localized one, may + * hold its value per key. + */ +export type PlainData = { + [K in Extract]?: + | T[K] + | DeepPartial[K] + | Record[K]>; +}; + +/** + * The rows a fixture generator gives for its table: one, several or none. + */ +export type DatumGeneratorOutput = + | Row + | Row[] | undefined; +/** + * A fixture generator of a table class, called with the class registered into + * the schema: the decorated class or a subclass of it. + */ export type DatumGenerator = ( tableClass: Constructible, ) => DatumGeneratorOutput | Promise; export interface MetadataClass extends Constructible { key: symbol; + /** + * Builds the metadata a subclass starts from out of its parent's, copying + * whatever a decorator on the subclass would write to. Without it, the + * subclass shares its parent's metadata object. + */ + inherit?: (parent: TMetadata) => TMetadata; + /** + * Brings metadata stored under the shared key by an older copy of the + * package to this copy's shape. Returns `stored` itself when it is already + * current. + */ + upgrade?: (stored: TMetadata) => TMetadata; } /** @@ -35,13 +74,50 @@ export class DatumStaticMetadata { "@antelopejs/interface-database-decorators/DatumStaticMetadata@1", ); + /** + * The metadata of a subclass of a table: a copy of its parent's, so that + * decorators on the subclass never write into the parent or its siblings. + * The bound schema is not copied: `RegisterSchema` binds the parent after a + * subclass may have been read, so the subclass looks the schema up by name. + */ + public static inherit(parent: DatumStaticMetadata): DatumStaticMetadata { + const copy = new DatumStaticMetadata(); + copy.tableName = parent.tableName; + copy.schemaName = parent.schemaName; + for (const [group, keys] of Object.entries(parent.indexes)) { + copy.indexes[group] = [...keys]; + } + for (const group of parent.crossInstanceIndexes) { + copy.crossInstanceIndexes.add(group); + } + for (const group of parent.uniqueIndexes) { + copy.uniqueIndexes.add(group); + } + for (const [name, type] of Object.entries(parent.fields)) { + copy.fields[name] = type; + } + copy.primary = parent.primary; + copy.generator = parent.generator; + return copy; + } + + /** + * Copies 0.1.8 and 0.1.9 of the package store the metadata without + * `uniqueIndexes`, under the same key and class shape as this copy. + */ + public static upgrade(stored: DatumStaticMetadata): DatumStaticMetadata { + return Object.hasOwn(stored, "uniqueIndexes") + ? stored + : Object.assign(new DatumStaticMetadata(), stored); + } + public tableName?: string; public schemaName?: string; public readonly indexes: Record> = {}; public readonly crossInstanceIndexes = new Set(); public readonly uniqueIndexes = new Set(); public readonly fields: Record = {}; - public primary: string = "_id"; + public primary = "_id" as const; public generator?: DatumGenerator; /** * Schema the table was registered into by `RegisterSchema`. @@ -54,7 +130,7 @@ export class DatumStaticMetadata { isCrossInstance: boolean = false, isUnique: boolean = false, ) { - if (!(group in this.indexes)) { + if (!Object.hasOwn(this.indexes, group)) { this.indexes[group] = []; } this.indexes[group].push(key); @@ -67,26 +143,35 @@ export class DatumStaticMetadata { } } +// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- exported; replacing TTarget with `object` clears this row and adds 6 (1 no-object-parameters, 5 no-unsafe-argument for the any-typed constructor callers pass) export function getMetadata( target: TTarget, metadataClass: MetadataClass, inherit: boolean = true, ): TMetadata { + // SAFETY: getMetadata alone defines metadata under metadataClass.key, always a TMetadata (new metadataClass, its inherit or its upgrade), so the read is a TMetadata or undefined; reflect-metadata types it any. const existingMetadata = Reflect.getOwnMetadata(metadataClass.key, target) as | TMetadata | undefined; if (existingMetadata) { - return existingMetadata; + const upgraded = metadataClass.upgrade?.(existingMetadata); + if (upgraded && upgraded !== existingMetadata) { + Reflect.defineMetadata(metadataClass.key, upgraded, target); + } + return upgraded ?? existingMetadata; } - const prototype: object | null = Object.getPrototypeOf(target); + const prototype: unknown = Object.getPrototypeOf(target); const inheritedMetadata = - inherit && prototype - ? (Reflect.getOwnMetadata(metadataClass.key, prototype) as - | TMetadata - | undefined) + inherit && + isObjectLike(prototype) && + Reflect.hasOwnMetadata(metadataClass.key, prototype) + ? getMetadata(prototype, metadataClass, false) : undefined; - const metadata = inheritedMetadata ?? new metadataClass(target); + const metadata = + inheritedMetadata && metadataClass.inherit + ? metadataClass.inherit(inheritedMetadata) + : (inheritedMetadata ?? new metadataClass(target)); Reflect.defineMetadata(metadataClass.key, metadata, target); return metadata; } diff --git a/src/database.ts b/src/database.ts index 0c7b39a..93cbfa4 100644 --- a/src/database.ts +++ b/src/database.ts @@ -7,10 +7,14 @@ import type { Table } from "./table"; import { getTablesForSchema } from "./schema"; import type { DatumGeneratorOutput } from "./common"; import { DatumStaticMetadata, getMetadata } from "./common"; -import { fromPlainData, toDatabase, triggerEvent } from "./modifiers/common"; +import { + fromPlainData, + type StoredRow, + toDatabase, + triggerEvent, +} from "./modifiers/common"; type TableDefinitions = Record>; -type TableEntry = ReturnType; /** * Registers a schema with the database adapter and runs any fixtures. @@ -100,10 +104,9 @@ async function insertFixtureData( if (rows.length === 0) { return; } - const payload: any = rows.length === 1 ? rows[0] : rows; const table = schema.instance().table(tableName); try { - await table.insert(payload); + await table.insert(rows.length === 1 ? rows[0] : rows); } catch (error) { if ((await table.count()) === 0) throw error; } @@ -114,10 +117,10 @@ async function insertFixtureData( * processes starting on an empty table write one row between them. */ function withStableId( - row: TableEntry, + row: StoredRow
, tableName: string, position: number, -): TableEntry { +): StoredRow
{ return row._id === undefined ? { ...row, _id: `${tableName}:fixture:${position}` } : row; @@ -126,7 +129,7 @@ function withStableId( function toFixtureRows( fixtureData: DatumGeneratorOutput, tableClass: Class
, -): TableEntry[] { +): StoredRow
[] { const entries = fixtureData ? Array.isArray(fixtureData) ? fixtureData @@ -139,6 +142,6 @@ function toFixtureRows( }); } -function isTableEntry(value: unknown): value is TableEntry { +function isTableEntry(value: unknown): value is object { return typeof value === "object" && value !== null; } diff --git a/src/internal/bound-schema.ts b/src/internal/bound-schema.ts new file mode 100644 index 0000000..ad50eca --- /dev/null +++ b/src/internal/bound-schema.ts @@ -0,0 +1,16 @@ +/** @internal @packageDocumentation */ + +import { Schema } from "@antelopejs/interface-database"; + +import type { DatumStaticMetadata } from "../common"; + +/** + * The schema a table is bound to: the one `RegisterSchema` bound it to, else + * the one registered under its schema name. A subclass starts without a bound + * schema, so it follows its parent's schema whenever `RegisterSchema` binds it. + */ +export function boundSchema({ schema, schemaName }: DatumStaticMetadata) { + return ( + schema ?? (schemaName === undefined ? undefined : Schema.get(schemaName)) + ); +} diff --git a/src/model.ts b/src/model.ts index 2a4e955..3f26f32 100644 --- a/src/model.ts +++ b/src/model.ts @@ -14,10 +14,12 @@ import { import { fromDatabase, fromPlainData, + type StoredRow, toDatabase, triggerEvent, } from "./modifiers/common"; import { isRecord } from "./modifiers/internal/kinds"; +import { boundSchema } from "./internal/bound-schema"; import { type InternalRecord, isEmptyInternal, @@ -27,17 +29,22 @@ import { DatumStaticMetadata, type DeepPartial, getMetadata, + type PlainData, } from "./common"; export type DataModel = { - readonly schemaName: string; + /** + * Schema the table class registers into with `@RegisterTable`; `undefined` + * for a table class that does not. + */ + readonly schemaName: string | undefined; readonly tableClass?: Constructible; new (database: DatabaseDev.SchemaInstance): { readonly database: DatabaseDev.SchemaInstance; readonly table: DatabaseDev.Table; }; fromDatabase(obj: any): T | undefined; - fromPlainData(obj: any): T; + fromPlainData(obj: PlainData | T): T; }; export type FieldError = { field: string; message: string }; @@ -68,6 +75,15 @@ function isCodec(type: FieldType): type is CodecLike & FieldType { ); } +/** + * One key {@link BasicDataModel}'s `getBy` matches against an index: a value, + * or a value proxy, as `Table.getAll` takes it. + */ +export type IndexKey = Exclude< + Parameters["getAll"]>[0], + unknown[] +>; + type InstanceIdProvider = (ctx: RequestContext) => InstanceId | undefined; function isInstanceIdProvider( @@ -102,7 +118,7 @@ export function BasicDataModel( // SAFETY: Model.fromDatabase returns undefined only for a falsy row, and the rows a table read or an index lookup returns are stored records, never null or undefined; the cast removes that undefined from the element type. dataR.map((row) => Model.fromDatabase(row)) as T[]; const Model = class Model { - public static readonly schemaName = schemaName as string; + public static readonly schemaName: string | undefined = schemaName; /** * Table class this model was built from. */ @@ -113,7 +129,7 @@ export function BasicDataModel( * @param obj Plain data object containing the table fields * @returns Table class instance */ - public static fromPlainData(obj: any) { + public static fromPlainData(obj: PlainData | T): T { return obj instanceof dataType ? obj : fromPlainData(obj, dataType); } @@ -133,7 +149,7 @@ export function BasicDataModel( * @param obj Table class instance * @returns Database-ready data */ - public static toDatabase(obj: any) { + public static toDatabase(obj: PlainData | T): StoredRow { const instance = obj instanceof dataType ? obj : Model.fromPlainData(obj); return toDatabase(instance); } @@ -143,6 +159,7 @@ export function BasicDataModel( * Pass `{ partial: true }` to skip fields not present on `obj` (patch semantics). */ public static validate( + // oxlint-disable-next-line anti-slop/no-unknown-parameters -- validate is the parser: it narrows obj with isRecord and decodes every field with its codec before reading it obj: unknown, options?: ValidateInputOptions, ): ValidationResult { @@ -167,6 +184,7 @@ export function BasicDataModel( public readonly table: DatabaseDev.Table; constructor(public readonly database: DatabaseDev.SchemaInstance) { + // oxlint-disable-next-line typescript/no-unsafe-assignment -- database is a SchemaInstance, so its table is a Table; a schema instance covers every table of its schema, so no type parameter of it states that this class's table holds this class's rows this.table = database.table(name); } @@ -187,9 +205,9 @@ export function BasicDataModel( * @param keys Index value(s) * @returns Array of Table class instances. */ - public getBy(index: keyof T, ...keys: any[]) { + public getBy(index: Extract, ...keys: IndexKey[]) { return this.table - .getAll(keys.length === 1 ? keys[0] : keys, index) + .getAll(keys.length === 1 ? keys[0] : keys, index) .then((dataR) => toInstances(dataR)); } @@ -222,10 +240,10 @@ export function BasicDataModel( } if (allErrors.length > 0) throw makeValidationError(allErrors); } - const converter = (entry: any) => { + const converter = (entry: DeepPartial) => { const instance = Model.fromPlainData(entry); triggerEvent(instance, "insert"); - return Model.toDatabase(instance); + return toDatabase(instance); }; return this.table .insert(Array.isArray(obj) ? obj.map(converter) : converter(obj)) @@ -246,7 +264,7 @@ export function BasicDataModel( options?: ValidateOptions, ): Promise; public update( - obj: DeepPartial, + obj: DeepPartial & KeyedRow, options?: ValidateOptions, ): Promise; public async update(...args: UpdateArgs) { @@ -260,17 +278,16 @@ export function BasicDataModel( triggerEvent(instance, "update"); return this.table .get(parsed.id) - .update(withoutEmptyInternal(Model.toDatabase(instance))) + .update(withoutEmptyInternal(toDatabase(instance))) .run(); } const instance = Model.fromPlainData(parsed.data); triggerEvent(instance, "update"); - // SAFETY: parsed.data is the DeepPartial object the caller passed as the first argument (the id form was handled above), so reading the property named by the table's primary key is defined and gives undefined when absent; the next line asserts it is present. - const id = (parsed.data as any)[primaryKey]; + const id = parsed.data[primaryKey]; assert(id !== undefined, "Missing primary key in object update."); return this.table .get(id) - .update(withoutEmptyInternal(Model.toDatabase(instance))) + .update(withoutEmptyInternal(toDatabase(instance))) .run(); } @@ -301,11 +318,16 @@ function cacheKeyOf(instanceId: InstanceId | undefined): string | symbol { export type ValidateOptions = { validate?: boolean }; -type ParsedUpdate = { - id?: string; - data: DeepPartial; - options?: ValidateOptions; -}; +/** The part of a row an update without an id finds the row by. */ +type KeyedRow = Record; + +type ParsedUpdate = + | { id: string; data: DeepPartial; options?: ValidateOptions } + | { + id?: undefined; + data: DeepPartial & KeyedRow; + options?: ValidateOptions; + }; /** * Leaves out an `_internal` with nothing in it. The update writes that key as @@ -331,7 +353,10 @@ type IdUpdateArgs = [ data: DeepPartial, options?: ValidateOptions, ]; -type ObjectUpdateArgs = [data: DeepPartial, options?: ValidateOptions]; +type ObjectUpdateArgs = [ + data: DeepPartial & KeyedRow, + options?: ValidateOptions, +]; type UpdateArgs = IdUpdateArgs | ObjectUpdateArgs; function isIdUpdate(args: UpdateArgs): args is IdUpdateArgs { @@ -349,11 +374,12 @@ function parseUpdateArgs(args: UpdateArgs): ParsedUpdate { function schemaOf(cl: DataModel): Schema { if (!cl.tableClass) { - const schema = Schema.get(cl.schemaName); + const schema = + cl.schemaName === undefined ? undefined : Schema.get(cl.schemaName); assert(schema, `Schema not found for '${cl.schemaName}'`); return schema; } - const schema = getMetadata(cl.tableClass, DatumStaticMetadata).schema; + const schema = boundSchema(getMetadata(cl.tableClass, DatumStaticMetadata)); assert( schema, `${cl.tableClass.name} is not bound to a schema: call RegisterSchema("${cl.schemaName}") in the module that declares it`, diff --git a/src/modifiers/autodate.ts b/src/modifiers/autodate.ts index 7774044..3be1f82 100644 --- a/src/modifiers/autodate.ts +++ b/src/modifiers/autodate.ts @@ -17,11 +17,11 @@ type Options = { * does not require the Table class to incorporate a mixin. */ export class AutoDateModifier extends Modifier { - public insert(object: Record, field: string) { + public insert(object: Partial>, field: F) { object[field] = new Date(); } - public update(object: Record, field: string) { + public update(object: Partial>, field: F) { if (this.options.type === "updated") { object[field] = new Date(); } else { diff --git a/src/modifiers/common.ts b/src/modifiers/common.ts index ad388ef..638798b 100644 --- a/src/modifiers/common.ts +++ b/src/modifiers/common.ts @@ -1,21 +1,33 @@ import type * as DatabaseDev from "@antelopejs/interface-database"; -import { type Constructible, getMetadata } from "../common"; +import { + type Constructible, + getMetadata, + type PlainData, + type PlainRow, +} from "../common"; import { ignoredEventNames, - isCallable, + isEventHandler, isLocking, isOnlyEvents, isTwoWay, isWriteOnly, + prototypeOf, } from "./internal/kinds"; import { + assertModifiableField, + copyModifierLists, dataByKey, eventsByKey, fieldsByKey, floatingByKey, floatingOf, getInternal, + type InternalHolder, + type InternalRecord, + type ModifierState, + newModifiedField, unlockArgsOf, withRef, } from "./internal/records"; @@ -25,7 +37,9 @@ export const MixinSymbol = Symbol(); export type MixinType = T extends { [MixinSymbol]: infer A } ? A : never; export class Modifier< + // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Meta is read only by subclasses, through this.meta; removing it changes the published Modifier Meta extends object = object, + // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Options is read only by subclasses, through this.options; removing it changes the published Modifier Options extends object = object, > { protected meta!: Meta; @@ -40,6 +54,7 @@ export class OneWayModifier< > extends Modifier { public lock( _locked_value: LockedType | undefined, + // oxlint-disable-next-line anti-slop/no-unknown-parameters -- the published override point of every modifier; a modified field's value has its table class's type, which the shared modifier chain cannot name _value: unknown, ..._args: Args ): LockedType { @@ -47,6 +62,7 @@ export class OneWayModifier< } public test( locked_value: LockedType, + // oxlint-disable-next-line anti-slop/no-unknown-parameters -- a modified field's value has its table class's type, which the shared modifier chain cannot name value: unknown, ...args: Args ): boolean { @@ -64,6 +80,7 @@ export class TwoWayModifier< Meta extends object = object, Options extends object = object, > extends OneWayModifier { + // oxlint-disable-next-line anti-slop/no-unknown-returns -- the unlocked value has its table class's type, which the shared modifier chain cannot name public unlock(_locked_value: LockedType, ..._args: Args): unknown { throw new Error("Unimplemented"); } @@ -80,17 +97,22 @@ export class ContainerModifier< Meta extends object = object, Options extends object = object, > extends TwoWayModifier< + // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the container holds the field's values by key, typed by the table class, not by the modifier Record, [key: string], Meta, Options > { public override lock( + // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the container holds the field's values by key, typed by the table class, not by the modifier locked_value: Record | undefined, + // oxlint-disable-next-line anti-slop/no-unknown-parameters -- a modified field's value has its table class's type, which the shared modifier chain cannot name value: unknown, key: string, ) { if (key === "*") { + // SAFETY: the "*" key addresses the whole container, which the table class declares as the record of values per key; a non-record value is stored as given. + // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the container holds the field's values by key, typed by the table class, not by the modifier return value as Record; } return locked_value @@ -100,10 +122,15 @@ export class ContainerModifier< } : { [key]: value }; } + // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the container holds the field's values by key, typed by the table class, not by the modifier public override unlock(locked_value: Record, key: string) { - return key === "*" ? locked_value : locked_value[key]; + if (key === "*") { + return locked_value; + } + return Object.hasOwn(locked_value, key) ? locked_value[key] : undefined; } public override unlockrequest( + // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the container holds the field's values by key, typed by the table class, not by the modifier data: DatabaseDev.ValueProxy>, _meta: DatabaseDev.ValueProxy, key: DatabaseDev.ValueProxyOrValue, @@ -112,14 +139,20 @@ export class ContainerModifier< } } -interface InternalType { - meta: Record; - data: Record; -} +/** + * A table row as the database stores it: the fields of `T` that are set, with + * no prototype, and its modified fields locked in `_internal` instead of at the + * top level. + */ +export type StoredRow = Partial & InternalHolder; -type ModifierEvent = ( +/** + * A modifier method run for an event of the table instance (`insert`, + * `toDatabase`, ...), with the modifier as `this`. + */ +export type ModifierEvent = ( this: Modifier, - object: T, + object: { constructor: any }, field: PropertyKey, ...args: unknown[] ) => void; @@ -127,7 +160,7 @@ type ModifierEvent = ( interface ModifiedField { id: string; modifier: Modifier; - metaRef: { _ref: any }; + metaRef: { _ref: ModifierState | undefined }; } export class ModifiersStaticMetadata { @@ -135,11 +168,19 @@ export class ModifiersStaticMetadata { "@antelopejs/interface-database-decorators/ModifiersStaticMetadata@1", ); + /** + * The metadata of a subclass of a table: a copy of its parent's lists, so + * modifiers attached on the subclass never reach the parent or its siblings. + */ + public static inherit(parent: ModifiersStaticMetadata) { + return copyModifierLists(new ModifiersStaticMetadata(), parent); + } + public fields: Record> = {}; public events = new Map< string, - Array<[field: string, modified: ModifiedField]> + Array<[field: string, modified: ModifiedField, handler: ModifierEvent]> >(); } @@ -153,8 +194,13 @@ export class ModifiersDynamicMetadata { * Values assigned to a field before its modifiers could lock them, by field * name. Reads return them; `unlock` and `lock` assign them again. */ - floating: Record = {}; - fields: Record> = {}; + // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the values assigned before modifiers could lock them, by field name, have their table class's types + floating: Record = {}; + /** + * The arguments each modifier of a field was unlocked or locked with, by + * field name, then by modifier name. + */ + fields: Record> = {}; constructor(target: any) { this.staticMeta = getMetadata(target.constructor, ModifiersStaticMetadata); @@ -181,8 +227,8 @@ export class ModifiersDynamicMetadata { ); } - get(field: PropertyKey, internal: InternalType) { - if (field in this.floating) { + get(field: PropertyKey, internal: InternalRecord) { + if (Object.hasOwn(this.floating, field)) { return floatingByKey(this)[field]; } if (!this.canGet(field)) { @@ -216,7 +262,8 @@ export class ModifiersDynamicMetadata { ); } - set(field: PropertyKey, internal: InternalType, value: unknown) { + // oxlint-disable-next-line anti-slop/no-unknown-parameters -- a modified field's value has its table class's type, which the shared modifier chain cannot name + set(field: PropertyKey, internal: InternalRecord, value: unknown) { if (!this.canSet(field)) { floatingByKey(this)[field] = value; return; @@ -226,7 +273,7 @@ export class ModifiersDynamicMetadata { const prevlist: { id: string; modifier: Modifier; - metaRef: { _ref: any }; + metaRef: ModifiedField["metaRef"]; prev: unknown; }[] = []; let prev = dataByKey(internal)[field]; @@ -259,21 +306,27 @@ export class ModifiersDynamicMetadata { } } +/** The names of the modified fields of a table instance. */ +function fieldNamesOf(object: T) { + const metaTable = getMetadata(object.constructor, ModifiersStaticMetadata); + // SAFETY: the names attachModifier recorded are keys of the table class of object, so keys of T; Object.keys types them string. + return Object.keys(metaTable.fields) as Array; +} + /** * Builds a table instance from unlocked plain data: each key is assigned, so * modifiers lock the fields they can. Then runs the `fromPlainData` event. */ -export function fromPlainData( - rawObject: Record, - TableClass: T, -): InstanceType { - const shallowCopy: typeof rawObject = {}; - Object.setPrototypeOf(shallowCopy, TableClass.prototype); - for (const [key, val] of Object.entries(rawObject)) { - shallowCopy[key] = val; - } - triggerEvent(shallowCopy, "fromPlainData", rawObject); - return shallowCopy as InstanceType; +export function fromPlainData( + rawObject: PlainData, + TableClass: Constructible, +): T { + const instance = {}; + Object.setPrototypeOf(instance, prototypeOf(TableClass)); + Object.assign(instance, Object.fromEntries(Object.entries(rawObject))); + triggerEvent(instance, "fromPlainData", rawObject); + // SAFETY: instance gets the prototype of TableClass and the fields of rawObject, so it is a T, as fromPlainData returns; setPrototypeOf types no result. + return instance as T; } /** @@ -282,13 +335,11 @@ export function fromPlainData( */ export function toPlainData( object: T, -): Record { - const metaTable = getMetadata(object.constructor, ModifiersStaticMetadata); +): PlainRow { const result = { ...object }; if ("_internal" in result) delete result._internal; - for (const field of Object.keys(metaTable.fields)) { - const key = field; - result[key] = object[key]; + for (const field of fieldNamesOf(object)) { + result[field] = object[field]; } triggerEvent(object, "toPlainData", result); return result; @@ -302,9 +353,11 @@ export function fromDatabase( rawObject: any, TableClass: T, ): InstanceType { + // oxlint-disable-next-line typescript/no-unsafe-assignment -- rawObject is a stored row, typed any by the published fromDatabase signature; typing it T breaks consumers const shallowCopy = { ...rawObject }; - Object.setPrototypeOf(shallowCopy, TableClass.prototype); + Object.setPrototypeOf(shallowCopy, prototypeOf(TableClass)); triggerEvent(shallowCopy, "fromDatabase", rawObject); + // oxlint-disable-next-line typescript/no-unsafe-return -- shallowCopy is a copy of the any-typed rawObject return shallowCopy; } @@ -312,9 +365,9 @@ export function fromDatabase( * Converts a table instance to the locked record to store, `_internal` * included and floating values left out. Then runs the `toDatabase` event. */ -export function toDatabase( +export function toDatabase( object: T, -): Record { +): T & InternalHolder { const result = { ...object }; triggerEvent(object, "toDatabase", result); return result; @@ -325,38 +378,20 @@ type ExtractModifierOptions = type ExtractModifierArgs = T extends OneWayModifier ? Args : []; -export function attachModifier< - T extends Constructible, - M extends Constructible, ->( - TableClass: T, +export function attachModifier>( + TableClass: Function, Modifier: M, - field: keyof InstanceType, + field: PropertyKey, options: ExtractModifierOptions>, ) { - const entry: ModifiedField = { - id: Modifier.name, - modifier: new Modifier(), - metaRef: { _ref: {} }, - }; - Object.assign(entry.modifier, { - meta: new Proxy(entry.metaRef, { - get: (self, p) => { - if (!self._ref) return undefined; - return self._ref[p]; - }, - set: (self, p, v) => { - if (!self._ref) self._ref = {}; - return Reflect.set(self._ref, p, v, self._ref); - }, - }), - }); + const entry = newModifiedField(Modifier); Object.assign(entry.modifier, { options }); const metaTable = getMetadata(TableClass, ModifiersStaticMetadata); if (!isOnlyEvents(entry.modifier)) { - if (field in metaTable.fields) { + assertModifiableField(TableClass.name, entry.id, field); + if (Object.hasOwn(metaTable.fields, field)) { const list = fieldsByKey(metaTable)[field]; const last = list[list.length - 1]; if (isWriteOnly(last.modifier)) { @@ -375,13 +410,13 @@ export function attachModifier< } else { fieldsByKey(metaTable)[field] = [entry]; Object.defineProperty(TableClass.prototype, field, { - get: function () { + get: function (this: InternalHolder) { return getMetadata(this, ModifiersDynamicMetadata).get( field, getInternal(this), ); }, - set: function (value) { + set: function (this: InternalHolder, value) { getMetadata(this, ModifiersDynamicMetadata).set( field, getInternal(this), @@ -401,37 +436,32 @@ export function attachModifier< } for (const event of Object.getOwnPropertyNames(Modifier.prototype)) { - if ( - !(event in ignoredEventNames) && - isCallable(Modifier.prototype[event]) - ) { + const handler: unknown = Modifier.prototype[event]; + if (!Object.hasOwn(ignoredEventNames, event) && isEventHandler(handler)) { if (!metaTable.events.has(event)) { metaTable.events.set(event, []); } - eventsByKey(metaTable).get(event)?.push([field, entry]); + eventsByKey(metaTable).get(event)?.push([field, entry, handler]); } } } -function fieldsModifiedBy( - metaTable: ModifiersStaticMetadata, +function fieldsModifiedBy( + object: T, modifierClass: Constructible, fields?: Array, ): Array { - return (fields ?? (Object.keys(metaTable.fields) as Array)).filter( - (field) => - fieldsByKey(metaTable)[field]?.find( - ({ id }) => id === modifierClass.name, - ), + const metaTable = getMetadata(object.constructor, ModifiersStaticMetadata); + return (fields ?? fieldNamesOf(object)).filter((field) => + fieldsByKey(metaTable)[field]?.find(({ id }) => id === modifierClass.name), ); } -export function getModifiedFields< - T extends { constructor: any }, - M extends Constructible, ->(object: T, Modifier: M): (keyof T)[] { - const metaTable = getMetadata(object.constructor, ModifiersStaticMetadata); - return fieldsModifiedBy(metaTable, Modifier); +export function getModifiedFields( + object: T, + Modifier: Constructible, +): (keyof T)[] { + return fieldsModifiedBy(object, Modifier); } export function unlock< @@ -443,23 +473,23 @@ export function unlock< fields: Array | undefined, ...args: ExtractModifierArgs> ) { - const metaTable = getMetadata(object.constructor, ModifiersStaticMetadata); - const usedFields = fieldsModifiedBy(metaTable, Modifier, fields); + const usedFields = fieldsModifiedBy(object, Modifier, fields); const meta = getMetadata(object, ModifiersDynamicMetadata); for (const field of usedFields) { unlockArgsOf(meta, field)[Modifier.name] = args; if (field in meta.floating) { - object[field] = floatingOf(meta, field); + Object.assign(object, { [field]: floatingOf(meta, field) }); } } } type ModifierWithProxyArgs = { modifier: Constructible>; - args: DatabaseDev.ValueProxyOrValue[]; + args: unknown[]; }; + export function unlockrequest( - table: Constructible, + table: Constructible, object: DatabaseDev.ValueProxy, field: K, modifiers: ModifierWithProxyArgs[], @@ -468,18 +498,22 @@ export function unlockrequest( modifiers.map(({ modifier, args: modArgs }) => [modifier.name, modArgs]), ); - const stored = object; + const stored = object.cast<{ _internal: InternalRecord }>(); const meta = stored.key("_internal").key("meta"); const metaTable = getMetadata(table, ModifiersStaticMetadata); - return ( - fieldsByKey(metaTable)[field]?.reduce((data, { id, modifier }) => { - if (isWriteOnly(modifier) || !isTwoWay(modifier)) { - return data; - } - data = modifier.unlockrequest(data, meta.key(id), ...args[id]); - return data; - }, stored.key("_internal").key("data").key(field)) || stored.key(field) + const name = String(field); + const unlocked = fieldsByKey(metaTable)[field]?.reduce< + DatabaseDev.ValueProxy + >( + (data, { id, modifier }) => + isWriteOnly(modifier) || !isTwoWay(modifier) + ? data + : modifier.unlockrequest(data, meta.key(id), ...args[id]), + stored.key("_internal").key("data").key(name), ); + return (unlocked ?? object.cast().key(name)).cast< + T[K] + >(); } export function lock< @@ -491,8 +525,7 @@ export function lock< fields: Array | undefined, ...args: ExtractModifierArgs> | [] ) { - const metaTable = getMetadata(object.constructor, ModifiersStaticMetadata); - const usedFields = fieldsModifiedBy(metaTable, Modifier, fields); + const usedFields = fieldsModifiedBy(object, Modifier, fields); const meta = getMetadata(object, ModifiersDynamicMetadata); for (const field of usedFields) { const val = object[field]; @@ -529,6 +562,7 @@ export function testValue( return val === value; } +// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- exported; removing the parameter changes the published signature export function triggerEvent( object: T, event: string, @@ -545,9 +579,9 @@ export function triggerEvent( const eventEntries = metaTable.events.get(event); if (!eventEntries) return; - for (const [field, { id, modifier, metaRef }] of eventEntries) { - withRef(internal, id, modifier, metaRef, (modifier: Modifier) => - (modifier[event])(object, field, ...args), - ); + for (const [field, { id, modifier, metaRef }, handler] of eventEntries) { + withRef(internal, id, modifier, metaRef, (modifier) => { + handler.call(modifier, object, field, ...args); + }); } } diff --git a/src/modifiers/encryption.ts b/src/modifiers/encryption.ts index 4a9c853..b569dc7 100644 --- a/src/modifiers/encryption.ts +++ b/src/modifiers/encryption.ts @@ -11,6 +11,7 @@ import { } from "node:crypto"; import { attachModifier, MixinSymbol, TwoWayModifier } from "./common"; +import { isCallable, isRecord, isString } from "./internal/kinds"; type Options = { /** Encryption algorithm (for supported algorithms refer to {@link createCipheriv}) */ @@ -24,35 +25,56 @@ type Meta = Record; type LockedType = [ciphertext: string, iv: string, authTag?: string]; function date_encoder(this: any, key: string) { - return this[key] instanceof Date + const held: unknown = this[key]; + return held instanceof Date ? { - value: this[key].toUTCString(), + value: held.toUTCString(), type: "Date", } - : this[key]; + : held; } -function date_decoder(_key: string, value: any) { - return value !== null && - typeof value === "object" && - Object.keys(value).length === 2 && - Object.keys(value).includes("type") && - Object.keys(value).includes("value") && - value.type === "Date" - ? new Date(value.value) - : value; +type StoredValue = + | null + | boolean + | number + | string + | Date + | StoredValue[] + | { [key: string]: StoredValue }; + +/** + * The Date a stored marker `{ type: "Date", value }` stands for, when `value` + * is a text `toUTCString` produces, as `date_encoder` writes it. Any other + * object was stored by the caller. + */ +function dateOfMarker(candidate: StoredValue): Date | undefined { + if ( + !isRecord(candidate) || + Object.keys(candidate).length !== 2 || + candidate.type !== "Date" || + !isString(candidate.value) + ) { + return undefined; + } + const date = new Date(candidate.value); + return date.toUTCString() === candidate.value ? date : undefined; +} + +function date_decoder(_key: string, value: StoredValue) { + return dateOfMarker(value) ?? value; } function hasAuthTag( cipher: Cipher, ): cipher is Cipher & Pick { - return "getAuthTag" in cipher; + return "getAuthTag" in cipher && isCallable(cipher.getAuthTag); } function acceptsAuthTag( decipher: Decipher, ): decipher is Decipher & Pick { - return "setAuthTag" in decipher; + return "setAuthTag" in decipher && isCallable(decipher.setAuthTag); } /** @@ -70,6 +92,7 @@ export class EncryptionModifier extends TwoWayModifier< public override lock( _locked_value: LockedType | undefined, + // oxlint-disable-next-line anti-slop/no-unknown-parameters -- overrides OneWayModifier.lock; a modified field's value has its table class's type, which the shared modifier chain cannot name value: unknown, ): LockedType { const iv = randomBytes(this.options.ivSize || 16); diff --git a/src/modifiers/hash.ts b/src/modifiers/hash.ts index 9064046..f23cce8 100644 --- a/src/modifiers/hash.ts +++ b/src/modifiers/hash.ts @@ -23,6 +23,7 @@ import { getInternal, withRef, } from "./internal/records"; +import { isString } from "./internal/kinds"; type Options = { /** @@ -181,13 +182,13 @@ function withHashedField( throw new Error(`Field ${String(field)} is not hashed`); } const internal = getInternal(row); + const stored: unknown = dataByKey(internal)[field]; return withRef( internal, entry.id, entry.modifier, entry.metaRef, - (modifier) => - use(modifier, storedText(dataByKey(internal)[field])), + (modifier) => use(modifier, isString(stored) ? stored : ""), ); } @@ -212,6 +213,7 @@ export class HashModifier extends OneWayModifier< * Blocks the event loop for one scrypt run: a setter and the conversions * behind `insert` and `update` are synchronous. */ + // oxlint-disable-next-line anti-slop/no-unknown-parameters -- overrides OneWayModifier.lock; a modified field's value has its table class's type, which the shared modifier chain cannot name public override lock(locked_value: string | undefined, value: unknown) { if (value === undefined) { return value; diff --git a/src/modifiers/internal/kinds.ts b/src/modifiers/internal/kinds.ts index c23ef1e..3424312 100644 --- a/src/modifiers/internal/kinds.ts +++ b/src/modifiers/internal/kinds.ts @@ -1,5 +1,11 @@ /** @internal @packageDocumentation */ -import type { Modifier, OneWayModifier, TwoWayModifier } from "../common"; +import type { Constructible } from "../../common"; +import type { + Modifier, + ModifierEvent, + OneWayModifier, + TwoWayModifier, +} from "../common"; export function isWriteOnly(m: Modifier): m is OneWayModifier { return "lock" in m && !("unlock" in m); @@ -27,6 +33,42 @@ export function isCallable(value: unknown): value is CallableFunction { return typeof value === "function"; } +/** + * Whether a modifier prototype member is a method the engine can run for an + * event: any function takes the instance, the field and the event arguments. + */ +export function isEventHandler(value: unknown): value is ModifierEvent { + return typeof value === "function"; +} + +export function isString(value: unknown): value is string { + return typeof value === "string"; +} + +/** Whether a value is an object or a function: what a prototype chain holds. */ +export function isObjectLike(value: unknown): value is object { + return ( + (typeof value === "object" || typeof value === "function") && value !== null + ); +} + +/** The object the instances of a class inherit from. */ +export function prototypeOf(target: Constructible): object { + const prototype: unknown = target.prototype; + if (!isObjectLike(prototype)) { + throw new TypeError("A class prototype must be an object."); + } + return prototype; +} + +/** Whether `candidate` is the class `base` or a class extending it. */ +export function isSameOrSubclass( + candidate: Constructible, + base: Base, +): candidate is Base { + return candidate === base || base.isPrototypeOf(candidate); +} + export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } diff --git a/src/modifiers/internal/records.ts b/src/modifiers/internal/records.ts index 81ab310..fddc5cb 100644 --- a/src/modifiers/internal/records.ts +++ b/src/modifiers/internal/records.ts @@ -1,14 +1,72 @@ /** @internal @packageDocumentation */ -import type { DatumStaticMetadata } from "../../common"; +import type { Constructible, DatumStaticMetadata } from "../../common"; import type { Modifier, + ModifierEvent, ModifiersDynamicMetadata, ModifiersStaticMetadata, } from "../common"; type ModifiedFieldList = ModifiersStaticMetadata["fields"][string]; type ModifiedField = ModifiedFieldList[number]; -export type InternalRecord = Parameters[1]; + +/** What a modifier keeps for a field of a stored row, beside the locked value. */ +// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- what a modifier keeps for a field is known only to that modifier, so the shared record cannot name it +export type ModifierState = { [key: PropertyKey]: unknown }; + +/** + * Where a row holds its modified fields: the locked values by field name, and + * each modifier's state by modifier name. What the values hold is known to the + * modifiers that wrote them, not to the row. + */ +export interface InternalRecord { + meta: Record; + // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the locked value of a field is known only to the modifier that locked it, so the shared record cannot name it + data: { [field: PropertyKey]: unknown }; +} + +export interface InternalHolder { + constructor: any; + _internal?: InternalRecord; +} + +/** The modifier state of a table instance, created on first use. */ +export function getInternal(object: InternalHolder): InternalRecord { + if (!object._internal) { + object._internal = { + meta: {}, + data: {}, + }; + } + return object._internal; +} + +/** Whether a row's modifier state holds no locked value and no modifier state. */ +export function isEmptyInternal({ meta, data }: InternalRecord): boolean { + return Object.keys(meta).length === 0 && Object.keys(data).length === 0; +} + +/** + * Throws when a modifier cannot be attached to the field named `field`: the + * two names the table's own structures live under. + */ +export function assertModifiableField( + tableName: string, + modifierId: string, + field: PropertyKey, +): void { + const reason = + field === "constructor" + ? "the field would replace the class constructor that locates the metadata of every instance" + : field === "__proto__" + ? "the field would replace the prototype of the record of modified fields and never be recorded" + : undefined; + if (reason) { + throw new Error( + `Cannot add Modifier ${modifierId} on ${tableName}.${String(field)}: ${reason}.`, + ); + } +} export function fieldsByKey(meta: ModifiersStaticMetadata): { [field: PropertyKey]: ModifiedFieldList; @@ -20,7 +78,13 @@ export function eventsByKey( meta: ModifiersStaticMetadata, ): Map< string, - Array<[field: PropertyKey, modified: ModifiedFieldList[number]]> + Array< + [ + field: PropertyKey, + modified: ModifiedFieldList[number], + handler: ModifierEvent, + ] + > > { return meta.events; } @@ -32,6 +96,9 @@ export function unlockArgsOf( const byKey: { [field: PropertyKey]: ModifiersDynamicMetadata["fields"][string]; } = meta.fields; + if (!Object.hasOwn(byKey, field)) { + throw new TypeError(`${String(field)} holds no modifier arguments`); + } return byKey[field]; } @@ -44,9 +111,9 @@ export function fieldTypesByKey(meta: DatumStaticMetadata): { export function floatingOf( meta: { floating: Record }, field: PropertyKey, -): V { +): V | undefined { const byKey: { [field: PropertyKey]: V } = meta.floating; - return byKey[field]; + return Object.hasOwn(byKey, field) ? byKey[field] : undefined; } export function floatingByKey(meta: { floating: Record }): { @@ -61,18 +128,28 @@ export function dataByKey(internal: { data: Record }): { return internal.data; } -export function getInternal(object: any): InternalRecord { - if (!object._internal) { - object._internal = { - meta: {}, - data: {}, - }; - } - return object._internal; -} - -export function isEmptyInternal({ meta, data }: InternalRecord) { - return Object.keys(meta).length === 0 && Object.keys(data).length === 0; +/** A field entry for a new `Modifier`, whose `meta` reads and writes the record its `metaRef` points at. */ +export function newModifiedField( + Modifier: Constructible, +): ModifiedField { + const entry: ModifiedField = { + id: Modifier.name, + modifier: new Modifier(), + metaRef: { _ref: {} }, + }; + Object.assign(entry.modifier, { + meta: new Proxy(entry.metaRef, { + get: (self, p) => { + if (!self._ref) return undefined; + return self._ref[p]; + }, + set: (self, p, v) => { + if (!self._ref) self._ref = {}; + return Reflect.set(self._ref, p, v, self._ref); + }, + }), + }); + return entry; } export function withRef( @@ -89,3 +166,20 @@ export function withRef( } return ret; } + +/** The entries of a record or map with every list copied, not shared. */ +function listsCopied( + entries: Iterable, +): Array<[K, V[]]> { + return Array.from(entries, ([key, list]) => [key, [...list]]); +} + +/** Fills `copy` with the modified fields and events of `parent`, every list copied. */ +export function copyModifierLists( + copy: ModifiersStaticMetadata, + parent: ModifiersStaticMetadata, +): ModifiersStaticMetadata { + copy.fields = Object.fromEntries(listsCopied(Object.entries(parent.fields))); + copy.events = new Map(listsCopied(parent.events)); + return copy; +} diff --git a/src/modifiers/localization.ts b/src/modifiers/localization.ts index de324b1..c672436 100644 --- a/src/modifiers/localization.ts +++ b/src/modifiers/localization.ts @@ -66,7 +66,9 @@ export class LocalizationModifier extends ContainerModifier< * it in queries too (`unlockrequest`). */ public override lock( + // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the container modifier leaves the values by locale untyped locked_value: Record | undefined, + // oxlint-disable-next-line anti-slop/no-unknown-parameters -- overrides ContainerModifier.lock; a modified field's value has its table class's type, which the shared modifier chain cannot name value: unknown, key: string, ) { @@ -78,6 +80,7 @@ export class LocalizationModifier extends ContainerModifier< return withoutEmptyTexts(super.lock(locked_value, value, key)); } + // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the container modifier leaves the values by locale untyped public override unlock(locked_value: Record, key: string) { const unlocked = super.unlock(locked_value, key); if (unlocked !== undefined && unlocked !== EMPTY_TEXT) { @@ -89,6 +92,7 @@ export class LocalizationModifier extends ContainerModifier< } public override unlockrequest( + // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the container modifier leaves the values by locale untyped data: DatabaseDev.ValueProxy>, _meta: DatabaseDev.ValueProxy>, key: DatabaseDev.ValueProxyOrValue, diff --git a/src/relation.ts b/src/relation.ts index 00dfb2d..a338715 100644 --- a/src/relation.ts +++ b/src/relation.ts @@ -31,6 +31,20 @@ export class RelationStaticMetadata { "@antelopejs/interface-database-decorators/RelationStaticMetadata@1", ); + /** + * The metadata of a subclass of a table: a copy of its parent's, so that + * relations declared on the subclass never reach the parent or its siblings. + */ + public static inherit( + parent: RelationStaticMetadata, + ): RelationStaticMetadata { + const copy = new RelationStaticMetadata(); + for (const [field, options] of Object.entries(parent.relations)) { + copy.relations[field] = options; + } + return copy; + } + public readonly relations: Record = {}; } diff --git a/src/schema.ts b/src/schema.ts index 1f4f25a..4e02736 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -8,7 +8,7 @@ import { Schema } from "@antelopejs/interface-database"; import type { Table } from "./table"; import { DatumStaticMetadata, getMetadata } from "./common"; -const schemaTableRegistry: Record>> = {}; +const schemaTableRegistry = new Map>>(); export const RegisterTable: ( tableName: string, @@ -20,15 +20,14 @@ export const RegisterTable: ( metadata.schemaName = schemaName; metadata.schema = Schema.get(schemaName); - if (!(schemaName in schemaTableRegistry)) { - schemaTableRegistry[schemaName] = {}; - } - schemaTableRegistry[schemaName][tableName] = target; + const tables = schemaTableRegistry.get(schemaName) ?? {}; + tables[tableName] = target; + schemaTableRegistry.set(schemaName, tables); }, ); export function getTablesForSchema( schemaName: string, ): Record> | undefined { - return schemaTableRegistry[schemaName]; + return schemaTableRegistry.get(schemaName); } diff --git a/src/table.ts b/src/table.ts index 29f81cc..89445f4 100644 --- a/src/table.ts +++ b/src/table.ts @@ -8,13 +8,14 @@ import type { FieldType } from "@antelopejs/interface-database/schema"; import { type Class, type ClassDecorator, - MakeClassDecorator, MakePropertyDecorator, } from "@antelopejs/interface-core/decorators"; import { MixinSymbol, type MixinType } from "./modifiers/common"; +import { isSameOrSubclass, isString } from "./modifiers/internal/kinds"; import { fieldTypesByKey } from "./modifiers/internal/records"; import { type Constructible, DatumStaticMetadata, getMetadata } from "./common"; +import { boundSchema } from "./internal/bound-schema"; type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( k: infer I, @@ -103,13 +104,16 @@ export class Table { `${this.name} has no table name: decorate it with @RegisterTable`, ); if (isSchemaInstance(target)) { + // oxlint-disable-next-line typescript/no-unsafe-return -- target is a SchemaInstance, so its table is a Table; a schema instance covers every table of its schema, so no type parameter of it states that this class's table holds this class's rows return target.table(tableName); } + const schema = boundSchema(metadata); assert( - metadata.schema, + schema, `${this.name} is not bound to a schema: call RegisterSchema("${metadata.schemaName}") in the module that declares it`, ); - return metadata.schema.instance(target).table(tableName); + // oxlint-disable-next-line typescript/no-unsafe-return -- the schema instance is a SchemaInstance, so its table is a Table; a schema instance covers every table of its schema, so no type parameter of it states that this class's table holds this class's rows + return schema.instance(target).table(tableName); } /** @@ -132,10 +136,12 @@ export class Table { | ExtractTableMeta> > { const c = extendTable(this); - for (const mixin of others - .map((otherClass) => otherClass.prototype && new otherClass()) - .map((other) => other?.[MixinSymbol]) - .filter((mixin) => mixin?.prototype)) { + for (const mixin of others.flatMap((otherClass) => { + const mixin = otherClass.prototype + ? new otherClass()[MixinSymbol] + : undefined; + return mixin?.prototype ? [mixin] : []; + })) { for (const key of Object.getOwnPropertyNames(mixin.prototype)) { if (key !== "constructor" && !(key in c.prototype)) { Object.defineProperty(c.prototype, key, { @@ -157,7 +163,15 @@ export class Table { } } } - return c; + type Combined = TableClass< + InstanceType & + UnionToIntersection>>>, + ConstructorParameters, + | ExtractTableMeta> + | ExtractTableMeta> + >; + // SAFETY: c subclasses this and gets every mixin member the base lacks, so it has the instance type Combined computes from the mixin classes; the Object.defineProperty calls give TypeScript no way to derive it. + return c as Combined; } } @@ -199,7 +213,11 @@ export interface IndexOptions { export const Index = MakePropertyDecorator( (target, propertyKey, options?: IndexOptions) => { const metadata = getMetadata(target.constructor, DatumStaticMetadata); - const key = propertyKey; + assert( + isString(propertyKey), + `@Index on ${String(propertyKey)}: a database index names a field, never a symbol`, + ); + const key = propertyKey; metadata.addIndex( key, options?.group || key, @@ -223,11 +241,19 @@ type AwaitableArray = Promise | T | T[]; /** * Database Table class decorator to create default data on table creation. * - * @param generator Data generator + * @param generator Data generator, called with the table class registered + * into the schema: the decorated class or a subclass of it */ -export const Fixture: ( +export function Fixture( generator: (p: T) => AwaitableArray>>, -) => ClassDecorator = MakeClassDecorator((target, generator) => { - const metadata = getMetadata(target, DatumStaticMetadata); - metadata.generator = generator as unknown as DatumStaticMetadata["generator"]; -}); +): ClassDecorator { + return (target: T) => { + getMetadata(target, DatumStaticMetadata).generator = (tableClass) => { + assert( + isSameOrSubclass(tableClass, target), + `${tableClass.name} does not extend ${target.name}, whose fixture it inherited`, + ); + return generator(tableClass); + }; + }; +} diff --git a/src/tests/cross_instance.test.ts b/src/tests/cross_instance.test.ts index 7854f2d..2207858 100644 --- a/src/tests/cross_instance.test.ts +++ b/src/tests/cross_instance.test.ts @@ -114,7 +114,10 @@ describe("Tables and models across interface instances", () => { function stagesOf(query: Database.Table) { return query .build() - .map(({ stage, options }) => [stage, options?.id] as const); + .map( + ({ stage, options }) => + [stage, options && "id" in options ? options.id : undefined] as const, + ); } function BasicDataModelReadsOtherInstanceTest() { @@ -148,7 +151,10 @@ function GetModelFromOtherInstanceTest() { const ItemModel = Decorators.BasicDataModel(Item); const model = Other.GetModel(ItemModel, "t3"); expect(model.database).to.be.instanceOf(Database.SchemaInstance); - expect(model.database.build()[0].options.id).to.equal(boundSchemaId); + expect(model.database.build()[0]).to.deep.include({ + stage: "schema", + options: { id: boundSchemaId }, + }); } function LockFromOtherInstanceTest() { diff --git a/src/tests/database.test.ts b/src/tests/database.test.ts index 8046213..9c08a34 100644 --- a/src/tests/database.test.ts +++ b/src/tests/database.test.ts @@ -182,43 +182,10 @@ describe("Database - initialization", () => { delete val._id; delete val._instance; } - const sortById = (a: any, b: any) => a.id.localeCompare(b.id); + const sortById = (a: { id: string }, b: { id: string }) => + a.id.localeCompare(b.id); expect(result.sort(sortById)).to.deep.equal(testData.sort(sortById)); }); - it("writes one row when two processes register an id-less fixture at once", async () => { - @Fixture(() => [{ name: "Only row" }]) - @RegisterTable("fixture_race_no_id", "test-fixture-race-no-id") - class _TestTable extends Table { - declare name: string; - } - - await Promise.all([ - RegisterSchema("test-fixture-race-no-id"), - RegisterSchema("test-fixture-race-no-id"), - ]); - - const schema = Schema.get("test-fixture-race-no-id"); - if (!schema) throw new Error("Schema not found"); - expect(await schema.instance().table("fixture_race_no_id")).to.have.length( - 1, - ); - }); - it("does not fail the process that loses a fixture insert", async () => { - @Fixture(() => [{ _id: "only-row", name: "Only row" }]) - @RegisterTable("fixture_race_id", "test-fixture-race-id") - class _TestTable extends Table { - declare name: string; - } - - await Promise.all([ - RegisterSchema("test-fixture-race-id"), - RegisterSchema("test-fixture-race-id"), - ]); - - const schema = Schema.get("test-fixture-race-id"); - if (!schema) throw new Error("Schema not found"); - expect(await schema.instance().table("fixture_race_id")).to.have.length(1); - }); it("handles multiple tables", async () => { @RegisterTable("users", "test-multi-table-schema") class _UserTable extends Table { @@ -255,8 +222,14 @@ describe("Database - initialization", () => { } const model = GetModel(BasicDataModel(Reloaded), "late-instance"); - expect(model.database.build()[0].options.id).to.equal("test-late-schema"); - expect(Reloaded.table().build()[0].options.id).to.equal("test-late-schema"); + expect(model.database.build()[0]).to.deep.include({ + stage: "schema", + options: { id: "test-late-schema" }, + }); + expect(Reloaded.table().build()[0]).to.deep.include({ + stage: "schema", + options: { id: "test-late-schema" }, + }); }); it("populates TableDefinition.fields from @Field declarations", async () => { @RegisterTable("orders", "test-fields-schema") @@ -284,3 +257,40 @@ function getIndexDefinitions(schemaId: string, tableName: string) { if (!schema) throw new Error("Schema not found"); return schema.definition[tableName].indexes; } + +describe("Database - fixture race", () => { + it("writes one row when two processes register an id-less fixture at once", async () => { + @Fixture(() => [{ name: "Only row" }]) + @RegisterTable("fixture_race_no_id", "test-fixture-race-no-id") + class _TestTable extends Table { + declare name: string; + } + + await Promise.all([ + RegisterSchema("test-fixture-race-no-id"), + RegisterSchema("test-fixture-race-no-id"), + ]); + + const schema = Schema.get("test-fixture-race-no-id"); + if (!schema) throw new Error("Schema not found"); + expect(await schema.instance().table("fixture_race_no_id")).to.have.length( + 1, + ); + }); + it("does not fail the process that loses a fixture insert", async () => { + @Fixture(() => [{ _id: "only-row", name: "Only row" }]) + @RegisterTable("fixture_race_id", "test-fixture-race-id") + class _TestTable extends Table { + declare name: string; + } + + await Promise.all([ + RegisterSchema("test-fixture-race-id"), + RegisterSchema("test-fixture-race-id"), + ]); + + const schema = Schema.get("test-fixture-race-id"); + if (!schema) throw new Error("Schema not found"); + expect(await schema.instance().table("fixture_race_id")).to.have.length(1); + }); +}); diff --git a/src/tests/helpers/other-instance.ts b/src/tests/helpers/other-instance.ts index 1977978..c9730d1 100644 --- a/src/tests/helpers/other-instance.ts +++ b/src/tests/helpers/other-instance.ts @@ -35,7 +35,7 @@ function isModuleExports(value: unknown): value is object { } function exportsOf(loaded: Module): object { - const exported = loaded.exports; + const exported: unknown = loaded.exports; if (!isModuleExports(exported)) { throw new Error( `${loaded.filename} does not export an object or a function`, @@ -88,7 +88,7 @@ export function CreateOtherInstance(folders: string[]): ModuleRequire { if (isOwned) { return load(filename, parent); } - const required = Module.prototype.require.call(parent, request); + const required: unknown = Module.prototype.require.call(parent, request); if (!isModuleExports(required)) { throw new Error(`${request} does not export an object or a function`); } diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index e65fb0e..a1c566d 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -1,5 +1,6 @@ import { expect } from "chai"; -import { Schema } from "@antelopejs/interface-database"; +import { Schema, type ValueProxy } from "@antelopejs/interface-database"; +import type { Constructible } from "../common"; import { RegisterTable } from "../schema"; import { RegisterSchema } from "../database"; import { BasicDataModel, GetModel } from "../model"; @@ -11,6 +12,14 @@ import { unlockrequest } from "../modifiers/common"; import { asFieldType, numberCodec } from "./codec_helpers"; +function unlockAny( + table: Constructible, + row: ValueProxy, + field: K, +) { + return unlockrequest(table, row, field, []); +} + function getDatabase(schemaId: string) { const instance = Schema.get(schemaId)?.instance(); if (!instance) throw new Error(`Database not found: ${schemaId}`); @@ -53,7 +62,7 @@ describe("Integration - real database operations", () => { const userId = insertResult[0]; const retrievedUserFromModel = await userModel.get(userId); - const retrievedUserFromDatabase = await schemaInstance + const retrievedUserFromDatabase: unknown = await schemaInstance .table("users") .get(userId) .run(); @@ -69,7 +78,7 @@ describe("Integration - real database operations", () => { expect(retrievedUserFromModel?.secretData).to.deep.equal({ token: "secret-token-123", }); - expect(retrievedUserFromDatabase?.secretData).to.not.deep.equal({ + expect(retrievedUserFromDatabase).to.not.have.deep.property("secretData", { token: "secret-token-123", }); @@ -81,7 +90,10 @@ describe("Integration - real database operations", () => { expect(isPasswordValid).to.equal(true); } - const dbUser = await schemaInstance.table("users").get(userId).run(); + const dbUser: unknown = await schemaInstance + .table("users") + .get(userId) + .run(); expect(dbUser).to.have.property("email").that.equals("test@example.com"); expect(dbUser).to.have.property("name").that.equals("John Doe"); }); @@ -204,6 +216,11 @@ describe("Integration - real database operations", () => { ) .run(); expect(frenchTitles).to.deep.equal(["Only English"]); + const ids = await LocalizedContent.table(schemaInstance) + .filter((row) => row.key("_id").eq("content-untranslated")) + .map((row) => unlockAny(LocalizedContent, row, "_id")) + .run(); + expect(ids).to.deep.equal(["content-untranslated"]); }); it("manages encrypted sensitive data", async () => { @RegisterTable("sensitive_data", "integration-encrypt-schema") @@ -237,13 +254,13 @@ describe("Integration - real database operations", () => { expect(retrievedData?.creditCard).to.equal("4111-1111-1111-1111"); expect(retrievedData?.ssn).to.equal("123-45-6789"); - const dbData = await schemaInstance + const dbData: unknown = await schemaInstance .table("sensitive_data") .get(dataId) .run(); expect(dbData).to.have.property("userId").that.equals("user123"); - expect(dbData?.creditCard).to.not.equal("4111-1111-1111-1111"); - expect(dbData?.ssn).to.not.equal("123-45-6789"); + expect(dbData).to.not.have.property("creditCard", "4111-1111-1111-1111"); + expect(dbData).to.not.have.property("ssn", "123-45-6789"); }); }); diff --git a/src/tests/metadata_inheritance.test.ts b/src/tests/metadata_inheritance.test.ts new file mode 100644 index 0000000..652fb07 --- /dev/null +++ b/src/tests/metadata_inheritance.test.ts @@ -0,0 +1,211 @@ +import { expect } from "chai"; +import { RegisterTable } from "../schema"; +import { Field, Fixture, Index, Table } from "../table"; +import { Relation, RelationStaticMetadata } from "../relation"; +import { DatumStaticMetadata, getMetadata } from "../common"; +import { Hashed, HashModifier } from "../modifiers/hash"; +import { + attachModifier, + getModifiedFields, + ModifiersStaticMetadata, + OneWayModifier, +} from "../modifiers/common"; + +const schemaName = "metadata-inheritance-schema"; + +function fieldsOf(tableClass: typeof Table) { + return Object.keys(getMetadata(tableClass, DatumStaticMetadata).fields); +} + +describe("Metadata - subclasses of a table", () => { + it("keeps the table name and fields of each subclass apart", async () => { + class Base extends Table { + @Field("string") + declare createdBy: string; + } + + @RegisterTable("inheritance_users", schemaName) + class Users extends Base { + @Field("string") + declare email: string; + } + + @RegisterTable("inheritance_posts", schemaName) + class Posts extends Base { + @Field("string") + declare title: string; + } + + expect(getMetadata(Users, DatumStaticMetadata).tableName).to.equal( + "inheritance_users", + ); + expect(getMetadata(Posts, DatumStaticMetadata).tableName).to.equal( + "inheritance_posts", + ); + expect(getMetadata(Base, DatumStaticMetadata).tableName).to.equal( + undefined, + ); + expect(fieldsOf(Base)).to.deep.equal(["createdBy"]); + expect(fieldsOf(Users)).to.deep.equal(["createdBy", "email"]); + expect(fieldsOf(Posts)).to.deep.equal(["createdBy", "title"]); + }); + + it("keeps the indexes of each subclass apart", async () => { + class Base extends Table { + @Index() + declare slug: string; + } + + class Users extends Base { + @Index({ group: "slug", crossInstance: true }) + declare handle: string; + } + + class Posts extends Base { + @Index({ unique: true }) + declare title: string; + } + + const base = getMetadata(Base, DatumStaticMetadata); + const users = getMetadata(Users, DatumStaticMetadata); + const posts = getMetadata(Posts, DatumStaticMetadata); + + expect(base.indexes).to.deep.equal({ slug: ["slug"] }); + expect(users.indexes).to.deep.equal({ slug: ["slug", "handle"] }); + expect(posts.indexes).to.deep.equal({ slug: ["slug"], title: ["title"] }); + expect([...base.crossInstanceIndexes]).to.deep.equal([]); + expect([...users.crossInstanceIndexes]).to.deep.equal(["slug"]); + expect([...base.uniqueIndexes]).to.deep.equal([]); + expect([...posts.uniqueIndexes]).to.deep.equal(["title"]); + }); +}); + +describe("Metadata - subclasses of a table with fixtures, modifiers and relations", () => { + it("keeps the generator of each subclass apart", async () => { + class Base extends Table { + @Field("string") + declare name: string; + } + + @Fixture(() => ({ name: "user" })) + class Users extends Base {} + + @Fixture(() => ({ name: "post" })) + class Posts extends Base {} + + const generators = [Base, Users, Posts].map( + (tableClass) => getMetadata(tableClass, DatumStaticMetadata).generator, + ); + + expect(generators[0]).to.equal(undefined); + expect(generators[1]).to.be.a("function"); + expect(generators[2]).to.be.a("function"); + expect(generators[1]).to.not.equal(generators[2]); + }); + + it("calls an inherited fixture with the subclass registered", async () => { + @Fixture((tableClass) => ({ name: tableClass.name })) + class Base extends Table { + @Field("string") + declare name: string; + } + class Users extends Base {} + + const generator = getMetadata(Users, DatumStaticMetadata).generator; + + expect(await generator?.(Users)).to.deep.equal({ name: "Users" }); + expect(await generator?.(Base)).to.deep.equal({ name: "Base" }); + }); + + it("keeps the modified fields of each subclass apart", async () => { + class Base extends Table.with(HashModifier) { + @Hashed() + declare token: string; + } + + class Users extends Base { + @Hashed() + declare password: string; + } + + class Posts extends Base { + @Hashed() + declare secret: string; + } + + expect(getModifiedFields(new Base(), HashModifier)).to.deep.equal([ + "token", + ]); + expect(getModifiedFields(new Users(), HashModifier)).to.deep.equal([ + "token", + "password", + ]); + expect(getModifiedFields(new Posts(), HashModifier)).to.deep.equal([ + "token", + "secret", + ]); + }); + + it("keeps the modifier events of each subclass apart", async () => { + class Stamp extends OneWayModifier { + public override lock(_locked: string | undefined, value: string) { + return value; + } + + public toDatabase() {} + } + + class Base extends Table { + declare createdBy: string; + } + attachModifier(Base, Stamp, "createdBy", {}); + + class Users extends Base { + declare email: string; + } + attachModifier(Users, Stamp, "email", {}); + + const events = (tableClass: typeof Table) => + getMetadata(tableClass, ModifiersStaticMetadata).events.get("toDatabase"); + + expect(events(Base)).to.have.length(1); + expect(events(Users)).to.have.length(2); + }); + + it("keeps the relations of each subclass apart", async () => { + class Target extends Table {} + + class Base extends Table { + @Relation({ to: () => Target }) + declare owner: string; + } + + class Users extends Base { + @Relation({ to: () => Target }) + declare team: string; + } + + const relationsOf = (tableClass: typeof Table) => + Object.keys(getMetadata(tableClass, RelationStaticMetadata).relations); + + expect(relationsOf(Base)).to.deep.equal(["owner"]); + expect(relationsOf(Users)).to.deep.equal(["owner", "team"]); + }); + + it("starts a subclass from the metadata its parent holds", async () => { + class Base extends Table { + @Field("string") + declare createdBy: string; + } + + @RegisterTable("inheritance_inherited", schemaName) + class Registered extends Base {} + + class Child extends Registered {} + + expect(getMetadata(Child, DatumStaticMetadata).tableName).to.equal( + "inheritance_inherited", + ); + expect(fieldsOf(Child)).to.deep.equal(["createdBy"]); + }); +}); diff --git a/src/tests/modifiers/encryption_date_marker.test.ts b/src/tests/modifiers/encryption_date_marker.test.ts new file mode 100644 index 0000000..ac507fc --- /dev/null +++ b/src/tests/modifiers/encryption_date_marker.test.ts @@ -0,0 +1,86 @@ +import { expect } from "chai"; +import { Field, Table } from "../../table"; +import { Encrypted, EncryptionModifier } from "../../modifiers/encryption"; + +type Stored = + | null + | string + | number + | Date + | Stored[] + | { [key: string]: Stored }; + +class Card extends Table.with(EncryptionModifier) { + @Encrypted({ secretKey: "12345678901234567890123456789012" }) + @Field("any") + declare payload: Stored; +} + +class DatedCard extends Table.with(EncryptionModifier) { + @Encrypted({ secretKey: "12345678901234567890123456789012" }) + @Field("any") + declare payload: { at: Date }; +} + +function roundTrip(stored: Stored) { + const card = new Card(); + card.payload = stored; + return card.payload; +} + +describe("Modifiers - encryption - Date marker", () => { + it("returns a Date as a Date", async () => { + const date = new Date("2022-12-01T10:20:30Z"); + + const read = roundTrip({ at: date }); + + expect(read).to.deep.equal({ at: date }); + }); + + it("returns a Date held at the top level as a Date", async () => { + const date = new Date("2022-12-01T10:20:30Z"); + + const read = roundTrip(date); + + expect(read).to.be.instanceOf(Date); + expect(read).to.deep.equal(date); + }); + + it("returns an invalid Date as an invalid Date", async () => { + const dated = new DatedCard(); + dated.payload = { at: new Date(Number.NaN) }; + + expect(dated.payload.at).to.be.instanceOf(Date); + expect(Number.isNaN(dated.payload.at.getTime())).to.equal(true); + }); + + for (const stored of [ + { type: "Date", value: 123 }, + { type: "Date", value: "not a date" }, + { type: "Date", value: "2022-12-01" }, + { type: "Date", value: null }, + { type: "Date", value: ["Thu, 01 Dec 2022 10:20:30 GMT"] }, + { type: "Date", value: { year: 2022 } }, + ]) { + it(`returns ${JSON.stringify(stored)} as stored`, async () => { + expect(roundTrip(stored)).to.deep.equal(stored); + expect(roundTrip({ nested: stored })).to.deep.equal({ nested: stored }); + }); + } + + it("returns an object with other keys than the marker's as stored", async () => { + const stored = { + type: "Date", + value: "Thu, 01 Dec 2022 10:20:30 GMT", + note: "kept", + }; + + expect(roundTrip(stored)).to.deep.equal(stored); + }); + + it("returns an object of another type as stored", async () => { + const stored = { type: "Money", value: "Thu, 01 Dec 2022 10:20:30 GMT" }; + + expect(roundTrip(stored)).to.deep.equal(stored); + }); +}); diff --git a/src/tests/modifiers/inherited_names.test.ts b/src/tests/modifiers/inherited_names.test.ts new file mode 100644 index 0000000..190a3e5 --- /dev/null +++ b/src/tests/modifiers/inherited_names.test.ts @@ -0,0 +1,118 @@ +import { expect } from "chai"; +import { Table } from "../../table"; +import { Hashed, HashModifier } from "../../modifiers/hash"; +import { LocalizationModifier, Localized } from "../../modifiers/localization"; +import { + attachModifier, + getModifiedFields, + OneWayModifier, + triggerEvent, +} from "../../modifiers/common"; + +const PROTOTYPE_MEMBER_NAMES = [ + "toString", + "valueOf", + "hasOwnProperty", + "isPrototypeOf", + "propertyIsEnumerable", + "toLocaleString", +]; + +describe("Modifiers - names shared with Object.prototype members", () => { + for (const name of PROTOTYPE_MEMBER_NAMES) { + it(`attaches a modifier to a field named ${name}`, async () => { + class Account extends Table.with(HashModifier) {} + Hashed()(Account.prototype, name); + + expect(getModifiedFields(new Account(), HashModifier)).to.deep.equal([ + name, + ]); + expect(Object.getOwnPropertyDescriptor(Account.prototype, name)) + .to.have.property("set") + .that.is.a("function"); + }); + + it(`attaches a second modifier to a field named ${name}`, async () => { + class Content extends Table.with(HashModifier, LocalizationModifier) {} + Localized()(Content.prototype, name); + Hashed()(Content.prototype, name); + + expect(getModifiedFields(new Content(), HashModifier)).to.deep.equal([ + name, + ]); + expect( + getModifiedFields(new Content(), LocalizationModifier), + ).to.deep.equal([name]); + }); + } + + it("refuses a modified field named constructor", async () => { + class Account extends Table.with(HashModifier) {} + + expect(() => Hashed()(Account.prototype, "constructor")).to.throw( + "Account.constructor", + ); + expect(Account.prototype.constructor).to.equal(Account); + }); + + for (const [label, decorate] of Object.entries({ Hashed, Localized })) { + it(`refuses a field named __proto__ with ${label}`, async () => { + const Base = Table.with(HashModifier, LocalizationModifier); + class Account extends Base { + declare email: string; + } + Hashed()(Account.prototype, "email"); + + expect(() => decorate()(Account.prototype, "__proto__")).to.throw( + "Account.__proto__", + ); + expect(() => decorate()(Account.prototype, "__proto__")).to.throw( + "Account.__proto__", + ); + expect(Object.getPrototypeOf(Account.prototype)).to.equal(Base.prototype); + expect(getModifiedFields(new Account(), HashModifier)).to.deep.equal([ + "email", + ]); + }); + } + + it("keeps the fields of two tables apart", async () => { + class Left extends Table.with(HashModifier) {} + class Right extends Table.with(HashModifier) {} + Hashed()(Left.prototype, "toString"); + Hashed()(Right.prototype, "valueOf"); + + expect(getModifiedFields(new Left(), HashModifier)).to.deep.equal([ + "toString", + ]); + expect(getModifiedFields(new Right(), HashModifier)).to.deep.equal([ + "valueOf", + ]); + }); + + for (const eventName of ["toString", "valueOf", "hasOwnProperty"]) { + it(`registers a modifier method named ${eventName} as an event`, async () => { + const calls: string[] = []; + + class Account extends Table { + declare nickname: string; + } + class Watcher extends OneWayModifier { + public override lock(_locked: string | undefined, value: string) { + return value; + } + } + Object.defineProperty(Watcher.prototype, eventName, { + value: (_row: Account, field: PropertyKey) => { + calls.push(String(field)); + }, + configurable: true, + }); + attachModifier(Account, Watcher, "nickname", {}); + + triggerEvent(new Account(), eventName); + + expect(calls).to.deep.equal(["nickname"]); + }); + } +}); diff --git a/src/tests/own_key_lookups.test.ts b/src/tests/own_key_lookups.test.ts new file mode 100644 index 0000000..58afad4 --- /dev/null +++ b/src/tests/own_key_lookups.test.ts @@ -0,0 +1,65 @@ +import { expect } from "chai"; +import { getTablesForSchema, RegisterTable } from "../schema"; +import { Index, Table } from "../table"; +import { DatumStaticMetadata, getMetadata } from "../common"; +import { ContainerModifier } from "../modifiers/common"; +import { LocalizationModifier } from "../modifiers/localization"; + +const PROTOTYPE_MEMBER_NAMES = [ + "toString", + "constructor", + "hasOwnProperty", + "valueOf", +]; + +describe("Lookups by name - names shared with Object.prototype members", () => { + it("answers no tables for a schema nothing registered", async () => { + for (const name of PROTOTYPE_MEMBER_NAMES) { + expect(getTablesForSchema(name)).to.equal(undefined); + } + }); + + for (const name of PROTOTYPE_MEMBER_NAMES) { + it(`answers nothing for ${name} in a container modifier`, async () => { + expect(new ContainerModifier().unlock({ en: "x" }, name)).to.equal( + undefined, + ); + }); + + it(`answers the stored ${name} entry of a container modifier`, async () => { + expect( + new ContainerModifier().unlock( + Object.fromEntries([[name, "stored"]]), + name, + ), + ).to.equal("stored"); + }); + + it(`falls back when a localized field is read in the locale ${name}`, async () => { + const modifier = new LocalizationModifier(); + Object.assign(modifier, { options: { fallbackLocale: "en" } }); + + expect(modifier.unlock({ en: "Hello" }, name)).to.equal("Hello"); + }); + + it(`groups an index named ${name}`, async () => { + class Row extends Table {} + Index()(Row.prototype, name); + Index({ group: name })(Row.prototype, "other"); + + expect(getMetadata(Row, DatumStaticMetadata).indexes).to.deep.equal( + Object.fromEntries([[name, [name, "other"]]]), + ); + }); + + it(`registers the tables of a schema named ${name}`, async () => { + class Row extends Table {} + RegisterTable("rows", name)(Row); + + expect(getTablesForSchema(name)).to.deep.equal({ rows: Row }); + expect(getTablesForSchema(`${name}-never-registered`)).to.equal( + undefined, + ); + }); + } +}); diff --git a/src/tests/row_forms.test.ts b/src/tests/row_forms.test.ts new file mode 100644 index 0000000..6cdde9c --- /dev/null +++ b/src/tests/row_forms.test.ts @@ -0,0 +1,59 @@ +import { expect } from "chai"; + +import { BasicDataModel } from "../model"; +import { LocalizationModifier, Localized } from "../modifiers/localization"; +import { fromDatabase, toDatabase, toPlainData } from "../modifiers/common"; +import { Field, Table } from "../table"; + +class Article extends Table.with(LocalizationModifier) { + @Localized({ fallbackLocale: "en" }) + declare title: string; + + @Field("number") + declare views: number; +} + +const ArticleModel = BasicDataModel(Article, "articles"); + +describe("Row forms", () => { + it("reads a localized field given per locale", () => { + const article = ArticleModel.fromPlainData({ + title: { en: "Hello", fr: "Salut" }, + }).localize("*"); + + expect(article.localize("fr").title).to.equal("Salut"); + expect(article.localize("en").title).to.equal("Hello"); + }); + + it("locks a value given in the plain form once its locale is known", () => { + const article = ArticleModel.fromPlainData({ + title: "Bonjour", + views: 2, + }).localize("fr"); + + expect( + ArticleModel.toDatabase(article)._internal?.data.title, + ).to.deep.equal({ fr: "Bonjour" }); + }); + + it("leaves the locked fields out of the plain form", () => { + const article = ArticleModel.fromPlainData({ title: "Hi" }).localize("en"); + + const plain = toPlainData(article); + + expect(plain).to.deep.equal({ title: "Hi" }); + }); + + it("gives back a stored row as an instance of its table", () => { + const written = ArticleModel.fromPlainData({ + title: "Hi", + views: 3, + }).localize("en"); + + const read = fromDatabase(toDatabase(written), Article); + + expect(read).to.be.instanceOf(Article); + expect(read.views).to.equal(3); + expect(read.localize("en").title).to.equal("Hi"); + }); +}); diff --git a/src/tests/stored_metadata.test.ts b/src/tests/stored_metadata.test.ts new file mode 100644 index 0000000..30f42ab --- /dev/null +++ b/src/tests/stored_metadata.test.ts @@ -0,0 +1,101 @@ +import { expect } from "chai"; +import { Index, Table } from "../table"; +import { DatumStaticMetadata, getMetadata } from "../common"; + +/** + * The table metadata as copies 0.1.8 and 0.1.9 of the package store it under + * the shared metadata key, before `uniqueIndexes` was added to its shape. + */ +class LegacyDatumStaticMetadata { + public tableName?: string; + public schemaName?: string; + public readonly indexes: Record> = {}; + public readonly crossInstanceIndexes = new Set(); + public readonly fields: DatumStaticMetadata["fields"] = {}; + public primary: string = "_id"; + public generator?: DatumStaticMetadata["generator"]; + public schema?: DatumStaticMetadata["schema"]; + + addIndex(key: string, group: string, isCrossInstance: boolean = false) { + if (!Object.hasOwn(this.indexes, group)) { + this.indexes[group] = []; + } + this.indexes[group].push(key); + if (isCrossInstance) { + this.crossInstanceIndexes.add(group); + } + } +} + +function declaredByLegacyCopy(tableClass: typeof Table) { + const legacy = new LegacyDatumStaticMetadata(); + legacy.tableName = "legacy_rows"; + legacy.schemaName = "legacy-schema"; + legacy.primary = "code"; + legacy.addIndex("email", "email"); + legacy.addIndex("name", "search", true); + legacy.addIndex("city", "search"); + Reflect.defineMetadata(DatumStaticMetadata.key, legacy, tableClass); + return legacy; +} + +describe("Metadata - stored by an older copy of the package", () => { + it("reads the metadata as the current shape", async () => { + class Row extends Table {} + declaredByLegacyCopy(Row); + + const metadata = getMetadata(Row, DatumStaticMetadata); + + expect(metadata).to.be.instanceOf(DatumStaticMetadata); + expect([...metadata.uniqueIndexes]).to.deep.equal([]); + expect(metadata.uniqueIndexes.has("email")).to.equal(false); + }); + + it("keeps what the older copy stored", async () => { + class Row extends Table {} + declaredByLegacyCopy(Row); + + const metadata = getMetadata(Row, DatumStaticMetadata); + + expect(metadata.tableName).to.equal("legacy_rows"); + expect(metadata.schemaName).to.equal("legacy-schema"); + expect(metadata.primary).to.equal("code"); + expect(metadata.indexes).to.deep.equal({ + email: ["email"], + search: ["name", "city"], + }); + expect([...metadata.crossInstanceIndexes]).to.deep.equal(["search"]); + }); + + it("answers the same metadata on every read", async () => { + class Row extends Table {} + declaredByLegacyCopy(Row); + + expect(getMetadata(Row, DatumStaticMetadata)).to.equal( + getMetadata(Row, DatumStaticMetadata), + ); + }); + + it("records a unique index declared after the older copy", async () => { + class Row extends Table {} + declaredByLegacyCopy(Row); + + Index({ unique: true })(Row.prototype, "code"); + + const metadata = getMetadata(Row, DatumStaticMetadata); + expect([...metadata.uniqueIndexes]).to.deep.equal(["code"]); + expect(metadata.indexes.code).to.deep.equal(["code"]); + }); + + it("starts a subclass from the metadata the older copy stored", async () => { + class Base extends Table {} + declaredByLegacyCopy(Base); + class Child extends Base {} + + const metadata = getMetadata(Child, DatumStaticMetadata); + + expect(metadata).to.be.instanceOf(DatumStaticMetadata); + expect(metadata.uniqueIndexes.has("email")).to.equal(false); + expect(metadata.indexes).to.have.property("email"); + }); +}); diff --git a/src/tests/subclass_schema_binding.test.ts b/src/tests/subclass_schema_binding.test.ts new file mode 100644 index 0000000..311e158 --- /dev/null +++ b/src/tests/subclass_schema_binding.test.ts @@ -0,0 +1,92 @@ +import { expect } from "chai"; +import { Schema } from "@antelopejs/interface-database"; +import { DatumStaticMetadata, getMetadata } from "../common"; +import { boundSchema } from "../internal/bound-schema"; +import { RegisterTable } from "../schema"; +import { RegisterSchema } from "../database"; +import { BasicDataModel, GetModel } from "../model"; +import { Field, Table } from "../table"; + +describe("Schema binding - subclasses of a table", () => { + it("binds a subclass whose model is built before RegisterSchema ran", async () => { + @RegisterTable("binding_users", "binding-before-schema") + class Users extends Table { + @Field("string") + declare name: string; + } + class Admin extends Users {} + + const AdminModel = BasicDataModel(Admin); + await RegisterSchema("binding-before-schema"); + + const model = GetModel(AdminModel, "binding-instance"); + expect(model.database.build()[0]).to.deep.include({ + stage: "schema", + options: { id: "binding-before-schema" }, + }); + expect(Admin.table().build()[0]).to.deep.include({ + stage: "schema", + options: { id: "binding-before-schema" }, + }); + }); + + it("binds a subclass declared after RegisterSchema ran", async () => { + @RegisterTable("binding_late_users", "binding-after-schema") + class Users extends Table { + @Field("string") + declare name: string; + } + await RegisterSchema("binding-after-schema"); + class Admin extends Users {} + + const model = GetModel(BasicDataModel(Admin), "binding-instance"); + expect(model.database.build()[0]).to.deep.include({ + stage: "schema", + options: { id: "binding-after-schema" }, + }); + expect(Admin.table().build()[0]).to.deep.include({ + stage: "schema", + options: { id: "binding-after-schema" }, + }); + }); + + it("binds a subclass that registers its own table to its own schema", async () => { + @RegisterTable("binding_parents", "binding-parent-schema") + class Parent extends Table { + @Field("string") + declare name: string; + } + @RegisterTable("binding_children", "binding-child-schema") + class Child extends Parent {} + + await RegisterSchema("binding-parent-schema"); + await RegisterSchema("binding-child-schema"); + + expect(Parent.table().build()[0]).to.deep.include({ + stage: "schema", + options: { id: "binding-parent-schema" }, + }); + expect(Child.table().build()[0]).to.deep.include({ + stage: "schema", + options: { id: "binding-child-schema" }, + }); + }); + + it("follows the schema its parent is bound to when RegisterSchema runs again", async () => { + @RegisterTable("binding_rebound", "binding-rebound-schema") + class Users extends Table { + @Field("string") + declare name: string; + } + await RegisterSchema("binding-rebound-schema"); + class Admin extends Users {} + const adminMetadata = getMetadata(Admin, DatumStaticMetadata); + const first = boundSchema(adminMetadata); + + await RegisterSchema("binding-rebound-schema"); + + const second = Schema.get("binding-rebound-schema"); + expect(second).to.not.equal(first); + expect(boundSchema(adminMetadata)).to.equal(second); + }); +}); diff --git a/src/tests/table.test.ts b/src/tests/table.test.ts index 7277bfb..d43a614 100644 --- a/src/tests/table.test.ts +++ b/src/tests/table.test.ts @@ -1,3 +1,4 @@ +import { AssertionError } from "node:assert"; import { expect } from "chai"; import { MixinSymbol } from "../modifiers/common"; import { Field, Fixture, Index, Table } from "../table"; @@ -50,6 +51,18 @@ describe("Table - decorators", () => { expect(metadata.indexes.user_search).to.deep.equal(["name", "email"]); expect(metadata.indexes.age_range).to.deep.equal(["age"]); }); + it("refuses an index on a symbol-keyed field", () => { + const secret = Symbol("secret"); + const declare = () => { + class TestTable extends Table { + @Index() + [secret]!: string; + } + return TestTable; + }; + + expect(declare).to.throw(AssertionError); + }); it("creates table with fixture data", async () => { const testData = [ { id: "1", name: "Test User 1" },