electrodb

A library to more easily create and interact with multiple entities and heretical relationships in dynamodb

Usage no npm install needed!

<script type="module">
  import electrodb from 'https://cdn.skypack.dev/electrodb';
</script>

README

ElectroDB

Coverage Status Coverage Status npm bundle size Build Status Runkit Demo

ElectroDB ElectroDB is a DynamoDB library to ease the use of having multiple entities and complex hierarchical relationships in a single DynamoDB table.

Please submit issues/feedback or reach out on Twitter @tinkertamper.


Introducing: The NEW ElectroDB Playground

Try out and share ElectroDB Models, Services, and Single Table Design at electrodb.fun


Features


Turn this

tasks
  .patch({ 
    team: "core",
    task: "45-662", 
    project: "backend"
  })
  .set({ status: "open" })
  .add({ points: 5 })
  .append({ 
    comments: [{
      user: "janet",
      body: "This seems half-baked."
    }] 
  })
  .where(( {status}, {eq} ) => eq(status, "in-progress"))
  .go();

Into This

{
    "UpdateExpression": "SET #status = :status_u0, #points = #points + :points_u0, #comments = list_append(#comments, :comments_u0), #updatedAt = :updatedAt_u0, #gsi1sk = :gsi1sk_u0",
    "ExpressionAttributeNames": {
        "#status": "status",
        "#points": "points",
        "#comments": "comments",
        "#updatedAt": "updatedAt",
        "#gsi1sk": "gsi1sk"
    },
    "ExpressionAttributeValues": {
        ":status0": "in-progress",
        ":status_u0": "open",
        ":points_u0": 5,
        ":comments_u0": [
            {
                "user": "janet",
                "body": "This seems half-baked."
            }
        ],
        ":updatedAt_u0": 1630977029015,
        ":gsi1sk_u0": "$assignments#tasks_1#status_open"
    },
    "TableName": "your_table_name",
    "Key": {
        "pk": "$taskapp#team_core",
        "sk": "$tasks_1#project_backend#task_45-662"
    },
    "ConditionExpression": "attribute_exists(pk) AND attribute_exists(sk) AND #status = :status0"
}

Try it out!


Table of Contents


Project Goals

ElectroDB focuses on simplifying the process of modeling, enforcing data constraints, querying across entities, and formatting complex DocumentClient parameters. Three important design considerations we're made with the development of ElectroDB:

  1. ElectroDB should be able to be useful without having to query the database itself [read more].
  2. ElectroDB should be able to be added to a project that already has been established tables, data, and access patterns [read more].
  3. ElectroDB should not require additional design considerations on top of those made for DynamoDB, and therefore should be able to be removed from a project at any time without sacrifice.

Installation

Install from NPM

npm install electrodb --save

Usage

Require/import Entity and/or Service from electrodb:

const {Entity, Service} = require("electrodb");
// or 
import {Entity, Service} from "electrodb";

Entities and Services

To see full examples of ElectroDB in action, go to the Examples section.

Entity allows you to create separate and individual business objects in a DynamoDB table. When queried, your results will not include other Entities that also exist the same table. This allows you to easily achieve single table design as recommended by AWS. For more detail, read Entities.

Service allows you to build relationships across Entities. A service imports Entity Models, builds individual Entities, and creates Collections to allow cross Entity querying. For more detail, read Services.

You can use Entities independent of Services, you do not need to import models into a Service to use them individually. However, If you intend to make queries that join or span multiple Entities you will need to use a Service.

Entities

In ElectroDB an Entity is represents a single business object. For example, in a simple task tracking application, one Entity could represent an Employee and or a Task that is assigned to an employee.

Require or import Entity from electrodb:

const {Entity} = require("electrodb");
// or
import {Entity} from "electrodb";

When using TypeScript, for strong type checking, be sure to either add your model as an object literal to the Entity constructor or create your model using const assertions with the as const syntax.

Services

In ElectroDB a Service represents a collection of related Entities. Services allow you to build queries span across Entities. Similar to Entities, Services can coexist on a single table without collision. You can use Entities independent of Services, you do not need to import models into a Service to use them individually. However, you do you need to use a Service if you intend make queries that join multiple Entities.

Require:

const {Service} = require("electrodb");
// or
import {Service} from "electrodb";

TypeScript Support

Previously it was possible to generate type definition files (.d.ts) for you Models, Entities, and Services with the Electro CLI. New with version 0.10.0 is TypeScript support for Entities and Services.

As of writing this, this functionality is still a work in progress, and enforcement of some of ElectroDB's query constraints have still not been written into the type checks. Most notably are the following constraints not yet enforced by the type checker, but are enforced at query runtime:

  • Sort Key Composite Attribute order is not strongly typed. Sort Key Composite Attributes must be provided in the order they are defined on the model to build the key appropriately. This will not cause an error at query runtime, be sure your partial Sort Keys are provided in accordance with your model to fully leverage Sort Key queries. For more information about composite attribute ordering see the section on Composite Attributes.
  • Put/Create/Update/Patch/Delete/Create operations that partially impact index composite attributes are not statically typed. When performing a put or update type operation that impacts a composite attribute of a secondary index, ElectroDB performs a check at runtime to ensure all composite attributes of that key are included. This is detailed more in the section Composite Attribute and Index Considerations.
  • Use of the params method does not yet return strict types.
  • Use of the raw or includeKeys query options do not yet impact the returned types.

If you experience any issues using TypeScript with ElectroDB, your feedback is very important, please create a GitHub issue, and it can be addressed.

See the section Exported TypeScript Types to read more about the useful types exported from ElectroDB.

TypeScript Services

New with version 0.10.0 is TypeScript support. To ensure accurate types with, TypeScript users should create their services by passing an Object literal or const object that maps Entity alias names to Entity instances.

const table = "my_table_name";
const employees = new Entity(EmployeesModel, { client, table });
const tasks = new Entity(TasksModel, { client, table });
const TaskApp = new Service({employees, tasks});

The property name you assign the entity will then be "alias", or name, you can reference that entity by through the Service. Aliases can be useful if you are building a service with multiple versions of the same entity or wish to change the reference name of an entity without impacting the schema/key names of that entity.

Services take an optional second parameter, similar to Entities, with a client and table. Using this constructor interface, the Service will utilize the values from those entities, if they were provided, or be passed values to override the client or table name on the individual entities.

Not yet available for TypeScript, this pattern will also accept Models, or a mix of Entities and Models, in the same object literal format.

Join

When using JavaScript, use join to add Entities or Models onto a Service.

NOTE: If using TypeScript, see Joining Entities at Service construction for TypeScript to learn how to "join" entities for use in a TypeScript project.

Independent Models

let table = "my_table_name";
let employees = new Entity(EmployeesModel, { client, table });
let tasks = new Entity(TasksModel, { client, table });

Joining Entity instances to a Service

// Joining Entity instances to a Service
let TaskApp = new Service("TaskApp", { client, table });
TaskApp
    .join(employees) // available at TaskApp.entities.employees
    .join(tasks);    // available at TaskApp.entities.tasks

Joining models to a Service

let TaskApp = new Service("TaskApp", { client, table });
TaskApp
    .join(EmployeesModel) // available at TaskApp.entities.employees (based on entity name in model)
    .join(TasksModel);    // available at TaskApp.entities.tasks (based on entity name in model)

Joining Entities or Models with an alias

let TaskApp = new Service("TaskApp", { client, table });
TaskApp
    .join("personnel", EmployeesModel) // available at TaskApp.entities.personnel
    .join("directives", TasksModel); // available at TaskApp.entities.directives

Joining Entities at Service construction for TypeScript

let TaskApp = new Service({
    personnel: EmployeesModel, // available at TaskApp.entities.personnel
    directives: TasksModel, // available at TaskApp.entities.directives
});

When joining a Model/Entity to a Service, ElectroDB will perform a number of validations to ensure that Entity conforms to expectations collectively established by all joined Entities.

  • Entity names must be unique across a Service.
  • Collection names must be unique across a Service.
  • All Collections map to on the same DynamoDB indexes with the same index field names. See Indexes.
  • Partition Key [Composite Attributes](#composite attribute-arrays) on a Collection must have the same attribute names and labels (if applicable). See Attribute Definitions.
  • The name of the Service in the Model must match the Name defined on the Service instance.
  • Joined instances must be type Model or Entity.
  • If the attributes of an Entity have overlapping names with other attributes in that service, they must all have compatible or matching attribute definitions.
  • All models conform to the same model format. If you created your model prior to ElectroDB version 0.9.19 see section Version 1 Migration.

Model

Create an Entity's schema. In the below example.

const DynamoDB = require("aws-sdk/clients/dynamodb");
const {Entity, Service} = require("electrodb");
const client = new DynamoDB.DocumentClient();
const EmployeesModel = {
    model: {
        entity: "employees",
        version: "1",
        service: "taskapp",
    },
    attributes: {
        employee: {
            type: "string",
            default: () => uuidv4(),
        },
        firstName: {
            type: "string",
            required: true,
        },
        lastName: {
            type: "string",
            required: true,
        },
        office: {
            type: "string",
            required: true,
        },
        title: {
            type: "string",
            required: true,
        },
        team: {
            type: ["development", "marketing", "finance", "product", "cool cats and kittens"],
            required: true,
        },
        salary: {
            type: "string",
            required: true,
        },
        manager: {
            type: "string",
        },
        dateHired: {
            type: "string",
            validate: /^\d{4}-\d{2}-\d{2}$/gi
        },
        birthday: {
            type: "string",
            validate: /^\d{4}-\d{2}-\d{2}$/gi
        },
    },
    indexes: {
        employee: {
            pk: {
                field: "pk",
                composite: ["employee"],
            },
            sk: {
                field: "sk",
                composite: [],
            },
        },
        coworkers: {
            index: "gsi1pk-gsi1sk-index",
            collection: "workplaces",
            pk: {
                field: "gsi1pk",
                composite: ["office"],
            },
            sk: {
                field: "gsi1sk",
                composite: ["team", "title", "employee"],
            },
        },
        teams: {
            index: "gsi2pk-gsi2sk-index",
            pk: {
                field: "gsi2pk",
                composite: ["team"],
            },
            sk: {
                field: "gsi2sk",
                composite: ["title", "salary", "employee"],
            },
        },
        employeeLookup: {
            collection: "assignments",
            index: "gsi3pk-gsi3sk-index",
            pk: {
                field: "gsi3pk",
                composite: ["employee"],
            },
            sk: {
                field: "gsi3sk",
                composite: [],
            },
        },
        roles: {
            index: "gsi4pk-gsi4sk-index",
            pk: {
                field: "gsi4pk",
                composite: ["title"],
            },
            sk: {
                field: "gsi4sk",
                composite: ["salary", "employee"],
            },
        },
        directReports: {
            index: "gsi5pk-gsi5sk-index",
            pk: {
                field: "gsi5pk",
                composite: ["manager"],
            },
            sk: {
                field: "gsi5sk",
                composite: ["team", "office", "employee"],
            },
        },
    },
};

const TasksModel = {
    model: {
        entity: "tasks",
        version: "1",
        service: "taskapp",
    },
    attributes: {
        task: {
            type: "string",
            default: () => uuid(),
        },
        project: {
            type: "string",
        },
        employee: {
            type: "string",
        },
        description: {
            type: "string",
        },
    },
    indexes: {
        task: {
            pk: {
                field: "pk",
                composite: ["task"],
            },
            sk: {
                field: "sk",
                composite: ["project", "employee"],
            },
        },
        project: {
            index: "gsi1pk-gsi1sk-index",
            pk: {
                field: "gsi1pk",
                composite: ["project"],
            },
            sk: {
                field: "gsi1sk",
                composite: ["employee", "task"],
            },
        },
        assigned: {
            collection: "assignments",
            index: "gsi3pk-gsi3sk-index",
            pk: {
                field: "gsi3pk",
                composite: ["employee"],
            },
            sk: {
                field: "gsi3sk",
                composite: ["project", "task"],
            },
        },
    },
};

Model Properties

Property Description
model.service Name of the application using the entity, used to namespace all entities
model.entity Name of the entity that the schema represents
model.version (optional) The version number of the schema, used to namespace keys
attributes An object containing each attribute that makes up the schema
indexes An object containing table indexes, including the values for the table's default Partition Key and Sort Key

Service Options

Optional second parameter

Property Description
table The name of the dynamodb table in aws.
client (optional) An instance of the docClient from the aws-sdk for use when querying a DynamoDB table. This is optional if you wish to only use the params functionality, but required if you actually need to query against a database.

Attributes

Attributes define an Entity record. The AttributeName represents the value your code will use to represent an attribute.

Pro-Tip: Using the field property, you can map an AttributeName to a different field name in your table. This can be useful to utilize existing tables, existing models, or even to reduce record sizes via shorter field names. For example, you may refer to an attribute as organization but want to save the attribute with a field name of org in DynamoDB.

Simple Syntax

Assign just the type of the attribute directly to the attribute name. Types currently supported options are "string", "number", "boolean", an array of strings representing a fixed set of possible values, or "any" which disables value type checking on that attribute.

attributes: {
    <AttributeName>: "string" | "number" | "boolean" | "list" | "map" | "set" | "any" | string[] | ReadonlyArray<string> 
}

Expanded Syntax

Use the expanded syntax build out more robust attribute options.

attributes: {
    <AttributeName>: {
        type: "string" | "number" | "boolean" | "list" | "map" | "set" | "any" | ReadonlyArray<string>;
        required?: boolean;
        default?: <type> | (() => <type>);
        validate?: RegExp | ((value: <type>) => void | string);
        field?: string;
        readOnly?: boolean;
        label?: string;
        cast?: "number"|"string"|"boolean";
        get?: (attribute: <type>, schema: any) => <type> | void | undefined;
        set?: (attribute?: <type>, schema?: any) => <type> | void | undefined; 
        watch: "*" | string[]
    }
}

NOTE: When using get/set in TypeScript, be sure to use the ?: syntax to denote an optional attribute on set

Attribute Definition

Property Type Required Types Description
type string, ReadonlyArray<string>, string[] yes all Accepts the values: "string", "number" "boolean", "map", "list", "set", an array of strings representing a finite list of acceptable values: ["option1", "option2", "option3"], or "any" which disables value type checking on that attribute.
required boolean no all Flag an attribute as required to be present when creating a record. This attribute also acts as a type of NOT NULL flag, preventing it from being removed directly.
hidden boolean no all Flag an attribute as hidden to remove the property from results before they are returned.
default value, () => value no all Either the default value itself or a synchronous function that returns the desired value. Applied before set and before required check.
validate RegExp, (value: any) => void, (value: any) => string no all Either regex or a synchronous callback to return an error string (will result in exception using the string as the error's message), or thrown exception in the event of an error.
field string no all The name of the attribute as it exists in DynamoDB, if named differently in the schema attributes. Defaults to the AttributeName as defined in the schema.
readOnly boolean no all Prevents an attribute from being updated after the record has been created. Attributes used in the composition of the table's primary Partition Key and Sort Key are read-only by default. The one exception to readOnly is for properties that also use the watch property, read attribute watching for more detail.
label string no all Used in index composition to prefix key composite attributes. By default, the AttributeName is used as the label.
cast "number", "string", "boolean" no all Optionally cast attribute values when interacting with DynamoDB. Current options include: "number", "string", and "boolean".
set (attribute, schema) => value no all A synchronous callback allowing you to apply changes to a value before it is set in params or applied to the database. First value represents the value passed to ElectroDB, second value are the attributes passed on that update/put
get (attribute, schema) => value no all A synchronous callback allowing you to apply changes to a value after it is retrieved from the database. First value represents the value passed to ElectroDB, second value are the attributes retrieved from the database.
watch Attribute[], "*" no root-only Define other attributes that will always trigger your attribute's getter and setter callback after their getter/setter callbacks are executed. Only available on root level attributes.
properties {[key: string]: Attribute} yes* map Define the properties available on a "map" attribute, required if your attribute is a map. Syntax for map properties is the same as root level attributes.
items Attribute yes* list Define the attribute type your list attribute will contain, required if your attribute is a list. Syntax for list items is the same as a single attribute.
items "string" "number" yes* set

Enum Attributes

When using TypeScript, if you wish to also enforce this type make sure to us the as const syntax. If TypeScript is not told this array is Readonly, even when your model is passed directly to the Entity constructor, it will not resolve the unique values within that array.

This may be desirable, however, as enforcing the type value can require consumers of your model to do more work to resolve the type beyond just the type string.

NOTE: Regardless of using TypeScript or JavaScript, ElectroDB will enforce values supplied match the supplied array of values at runtime.

The following example shows the differences in how TypeScript may enforce your enum value:

attributes: {
  myEnumAttribute1: {
      type: ["option1", "option2", "option3"]        // TypeScript enforces as `string[]`
  },
  myEnumAttribute2: {
    type: ["option1", "option2", "option3"] as const // TypeScript enforces as `"option1" | "option2" | "option3" | undefined`
  },
  myEnumAttribute3: {
    required: true,
    type: ["option1", "option2", "option3"] as const // TypeScript enforces as `"option1" | "option2" | "option3"`
  }
}

Map Attributes

Map attributes leverage DynamoDB's native support for object-like structures. The attributes within a Map are defined under the properties property; a syntax that mirrors the syntax used to define root level attributes. You are not limited in the types of attributes you can nest inside a map attribute.

attributes: {
  myMapAttribute: {
    type: "map",
    properties: {
      myStringAttribute: {
        type: "string"
      },
      myNumberAttribute: {
        type: "number"
      }
    }
  }
}

List Attributes

List attributes model array-like structures with DynamoDB's List type. The elements of a List attribute are defined using the items property. Similar to Map properties, ElectroDB does not restrict the types of items that can be used with a list.

attributes: {
  myStringList: { 
    type: "list",
    items: {
      type: "string"
    },
  },
  myMapList: {
    myMapAttribute: {
      type: "map",
      properties: {
        myStringAttribute: {
          type: "string"
        },
        myNumberAttribute: {
          type: "number"
        }
      }
    }
  }
}

Set Attributes

The Set attribute is arguably DynamoDB's most powerful type. ElectroDB supports String and Number Sets using the items property set as either "string" or "number".

In addition to having the same modeling benefits you get with other attributes, ElectroDB also simplifies the use of Sets by removing the need to use DynamoDB's special createSet class to work with Sets. ElectroDB Set Attributes accept Arrays, JavaScript native Sets, and objects from createSet as values. ElectroDB will manage the casting of values to a DynamoDB Set value prior to saving and ElectroDB will also convert Sets back to JavaScript arrays on retrieval.

NOTE: If you are using TypeScript, Sets are currently typed as Arrays to simplify the type system. Again, ElectroDB will handle the conversion of these Arrays without the need to use client.createSet().

attributes: {
  myStringSet: {
    type: "set",
    items: "string"
  },
  myNumberSet: {
    type: "set",
    items: "number"
  }
}

Attribute Getters and Setters

Using get and set on an attribute can allow you to apply logic before and just after modifying or retrieving a field from DynamoDB. Both callbacks should be pure synchronous functions and may be invoked multiple times during one query.

The first argument in an attribute's get or set callback is the value received in the query. The second argument, called "item", in an attribute's is an object containing the values of other attributes on the item as it was given or retrieved. If your attribute uses watch, the getter or setter of attribute being watched will be invoked before your getter or setter and the updated value will be on the "item" argument instead of the original.

NOTE: Using getters/setters on Composite Attributes is not recommended without considering the consequences of how that will impact your keys. When a Composite Attribute is supplied for a new record via a put or create operation, or is changed via a patch or updated operation, the Attribute's set callback will be invoked prior to formatting/building your record's keys on when creating or updating a record.

ElectroDB invokes an Attribute's get method in the following circumstances:

  1. If a field exists on an item after retrieval from DynamoDB, the attribute associated with that field will have its getter method invoked.
  2. After a put or create operation is performed, attribute getters are applied against the object originally received and returned.
  3. When using ElectroDB's attribute watching functionality, an attribute will have its getter callback invoked whenever the getter callback of any "watched" attributes are invoked. Note: The getter of an Attribute Watcher will always be applied after the getters for the attributes it watches.

ElectroDB invokes an Attribute's set callback in the following circumstances:

  1. Setters for all Attributes will always be invoked when performing a create or put operation.
  2. Setters will only be invoked when an Attribute is modified when performing a patch or update operation.
  3. When using ElectroDB's attribute watching functionality, an attribute will have its setter callback invoked whenever the setter callback of any "watched" attributes are invoked. Note: The setter of an Attribute Watcher will always be applied after the setters for the attributes it watches.

NOTE: As of ElectroDB 1.3.0, the watch property is only possible for root level attributes. Watch is currently not supported for nested attributes like properties on a "map" or items of a "list".

Attribute Watching

Attribute watching is a powerful feature in ElectroDB that can be used to solve many unique challenges with DynamoDB. In short, you can define a column to have its getter/setter callbacks called whenever another attribute's getter or setter callbacks are called. If you haven't read the section on Attribute Getters and Setters, it will provide you with more context about when an attribute's mutation callbacks are called.

Because DynamoDB allows for a flexible schema, and ElectroDB allows for optional attributes, it is possible for items belonging to an entity to not have all attributes when setting or getting records. Sometimes values or changes to other attributes will require corresponding changes to another attribute. Sometimes, to fully leverage some advanced model denormalization or query access patterns, it is necessary to duplicate some attribute values with similar or identical values. This functionality has many uses; below are just a few examples of how you can use watch:

NOTE: Using the watch property impacts the order of which getters and setters are called. You cannot watch another attribute that also uses watch, so ElectroDB first invokes the getters or setters of attributes without the watch property, then subsequently invokes the getters or setters of attributes who use watch.

myAttr: { 
  type: "string",
  watch: ["otherAttr"],
  set: (myAttr, {otherAttr}) => {
    // Whenever "myAttr" or "otherAttr" are updated from an `update` or `patch` operation, this callback will be fired. 
    // Note: myAttr or otherAttr could be independently undefined because either attribute could have triggered this callback
  },
  get: (myAttr, {otherAttr}) => {
    // Whenever "myAttr" or "otherAttr" are retrieved from a `query` or `get` operation, this callback will be fired. 
    // Note: myAttr or otherAttr could be independently undefined because either attribute could have triggered this callback.
  } 
}
Attribute Watching: Watch All

If your attributes needs to watch for any changes to an item, you can model this by supplying the watch property a string value of "*"

myAttr: { 
  type: "string",
  watch: "*", // "watch all"
  set: (myAttr, allAttributes) => {
    // Whenever an `update` or `patch` operation is performed, this callback will be fired. 
    // Note: myAttr or the attributes under `allAttributes` could be independently undefined because either attribute could have triggered this callback
  },
  get: (myAttr, allAttributes) => {
    // Whenever a `query` or `get` operation is performed, this callback will be fired. 
    // Note: myAttr or the attributes under `allAttributes` could be independently undefined because either attribute could have triggered this callback
  } 
}
Attribute Watching Examples

Example 1 - A calculated attribute that depends on the value of another attribute:

In this example, we have an attribute "fee" that needs to be updated any time an item's "price" attribute is updated. The attribute "fee" uses watch to have its setter callback called any time "price" is updated via a put, create, update, or patch operation.

Try it out!

{
  model: {
    entity: "products",
    service: "estimator",
    version: "1"
  },
  attributes: {
    product: {
      type: "string"
    },
    price: {
      type: "number",
              required: true
    },
    fee: {
      type: "number",
              watch: ["price"],
              set: (_, {price}) => {
        return price * .2;
      }
    }
  },
  indexes: {
    pricing: {
      pk: {
        field: "pk",
                composite: ["product"]
      },
      sk: {
        field: "sk",
                composite: []
      }
    }
  }
}

Example 2 - Making a virtual attribute that never persists to the database:

In this example we have an attribute "displayPrice" that needs its getter called anytime an item's "price" attribute is retrieved. The attribute "displayPrice" uses watch to return a formatted price string based whenever an item with a "price" attribute is queried. Additionally, "displayPrice" always returns undefined from its setter callback to ensure that it will never write data back to the table.

{
  model: {
    entity: "services",
    service: "costEstimator",
    version: "1"
  },
  attributes: {
    service: {
      type: "string"
    },
    price: {
      type: "number",
      required: true
    },
    displayPrice: {
      type: "string",
      watch: ["price"],
      get: (_, {price}) => {
        return "quot; + price;  
      },
      set: () => undefined
    }
  },
  indexes: {
    pricing: {
      pk: {
        field: "pk",
        composite: ["service"]
      },
      sk: {
        field: "sk",
        composite: []
      }
    }
  }
}

Example 3 - Creating a more filter-friendly version of an attribute without impacting the original attribute:

In this example we have an attribute "descriptionSearch" which will help our users easily filter for transactions by "description". To ensure our filters will not take into account a description's character casing, descriptionSearch duplicates the value of "description" so it can be used in filters without impacting the original "description" value. Without ElectroDB's watch functionality, to accomplish this you would either have to duplicate this logic or cause permanent modification to the property itself. Additionally, the "descriptionSearch" attribute has used hidden:true to ensure this value will not be presented to the user.

{
  model: {
    entity: "transaction",
    service: "bank",
    version: "1"
  },
  attributes: {
    accountNumber: {
      type: "string"
    },
    transactionId: {
      type: "string"
    },
    amount: {
      type: "number",
    },
    description: {
      type: "string",
    },
    descriptionSearch: {
      type: "string",
      hidden: true,
      watch: ["description"],
      set: (_, {description}) => {
        if (typeof description === "string") {
            return description.toLowerCase();
        }
      }
    }
  },
  indexes: {
    transactions: {
      pk: {
        field: "pk",
        composite: ["accountNumber"]
      },
      sk: {
        field: "sk",
        composite: ["transactionId"]
      }
    }
  }
}

Example 4 - Creating an updatedAt property:

In this example we can easily create both updatedAt and createdAt attributes on our model. createdAt will use ElectroDB's set and readOnly attribute properties, while updatedAt will make use of readOnly, and watch with the "watchAll" syntax: {watch: "*"}. By supplying an asterisk, instead of an array of attribute names, attributes can be defined to watch all changes to all attributes.

Using watch in conjunction with readOnly is another powerful modeling technique. This combination allows you to model attributes that can only be modified via the model and not via the user. This is useful for attributes that need to be locked down and/or strictly calculated.

Notable about this example is that both updatedAt and createdAt use the set property without using its arguments. The readOnly only prevents modification of an attributes on update, and patch. By disregarding the arguments passed to set, the updatedAt and createdAt attributes are then effectively locked down from user influence/manipulation.

{
  model: {
    entity: "transaction",
    service: "bank",
    version: "1"
  },
  attributes: {
    accountNumber: {
      type: "string"
    },
    transactionId: {
      type: "string"
    },
    description: {
      type: "string",
    },
    createdAt: {
      type: "number",
      readOnly: true,
      set: () => Date.now()
    },
    updatedAt: {
      type: "number",
      readOnly: true,
      watch: "*",
      set: () => Date.now()
    },
    
  },
  indexes: {
    transactions: {
      pk: {
        field: "pk",
        facets: ["accountNumber"]
      },
      sk: {
        field: "sk",
        facets: ["transactionId"]
      }
    }
  }
}

Calculated Attributes

See: Attribute Watching (Example 1).

Virtual Attributes

See: Attribute Watching (Example 2).

CreatedAt and UpdatedAt Attributes

See: Attribute Watching (Example 4).

Attribute Validation

The validation property allows for multiple function/type signatures. Here the different combinations ElectroDB supports: signature | behavior ----------------------- | -------- Regexp | ElectroDB will call .test(val) on the provided regex with the value passed to this attribute (value: T) => string | If a string value with length is returned, the text will be considered the reason the value is invalid. It will generate a new exception this text as the message. (value: T) => boolean | If a boolean value is returned, true or truthy values will signify than a value is invalid while false or falsey will be considered valid. (value: T) => void | A void or undefined value is returned, will be treated as successful, in this scenario you can throw an Error yourself to interrupt the query

Indexes

When using ElectroDB, indexes are referenced by their AccessPatternName. This allows you to maintain generic index names on your DynamoDB table, but reference domain specific names while using your ElectroDB Entity. These will often be referenced as "Access Patterns".

All DynamoDB table start with at least a PartitionKey with an optional SortKey, this can be referred to as the "Table Index". The indexes object requires at least the definition of this Table Index Partition Key and (if applicable) Sort Key.

In your model, the Table Index this is expressed as an Access Pattern without an index property. For Secondary Indexes, use the index property to define the name of the index as defined on your DynamoDB table.

Within these AccessPatterns, you define the PartitionKey and (optionally) SortKeys that are present on your DynamoDB table and map the key's name on the table with the field property.

indexes: {
    [AccessPatternName]: {
        pk: {
            field: string; 
            composite: AttributeName[];
            template?: string;
        },
        sk?: {
            field: string;
            composite: AttributesName[];
            template?: string;
        },
        index?: string
        collection?: string | string[]
    }
}
Property Type Required Description
pk object yes Configuration for the pk of that index or table
pk.composite string | string[] yes An array that represents the order in which attributes are concatenated to composite attributes the key (see Composite Attributes below for more on this functionality).
pk.template string no A string that represents the template in which attributes composed to form a key (see Composite Attribute Templates below for more on this functionality).
pk.field string yes The name of the attribute as it exists in DynamoDB, if named differently in the schema attributes.
pk.casing default upper lower
sk object no Configuration for the sk of that index or table
sk.composite string | string[] no Either an Array that represents the order in which attributes are concatenated to composite attributes the key, or a String for a composite attribute template. (see Composite Attributes below for more on this functionality).
sk.template string no A string that represents the template in which attributes composed to form a key (see Composite Attribute Templates below for more on this functionality).
sk.field string yes The name of the attribute as it exists in DynamoDB, if named differently in the schema attributes.
pk.casing default upper lower
index string no Required when the Index defined is a Secondary Index; but is left blank for the table's primary index.
collection string | string[] no Used when models are joined to a Service. When two entities share a collection on the same index, they can be queried with one request to DynamoDB. The name of the collection should represent what the query would return as a pseudo Entity. (see Collections below for more on this functionality).

Indexes Without Sort Keys

When using indexes without Sort Keys, that should be expressed as an index without an sk property at all. Indexes without an sk cannot have a collection, see Collections for more detail.

NOTE: It is generally recommended to always use Sort Keys when using ElectroDB as they allow for more advanced query opportunities. Even if your model doesn't need an additional property to define a unique record, having an sk with no defined composite attributes (e.g. an empty array) still opens the door to many more query opportunities like collections.

// ElectroDB interprets as index *not having* an SK.
{
  indexes: {
    myIndex: {
      pk: {
        field: "pk",
        composite: ["id"]
      }
    }
  }
}

Try it out!

Indexes With Sort Keys

When using indexes with Sort Keys, that should be expressed as an index with an sk property. If you don't wish to use the Sort Key in your model, but it does exist on the table, simply use an empty for the composite property. An empty array is still very useful, and opens the door to more query opportunities and access patterns like collections.

// ElectroDB interprets as index *having* SK, but this model doesnt assign any composite attributes to it.
{
  indexes: {
    myIndex: {
      pk: {
        field: "pk",
        composite: ["id"]
      },
      sk: {
        field: "sk",
        composite: []
      }
    }
  }
}

Try it out!

Numeric Keys

If you have an index where the Partition or Sort Keys are expected to be numeric values, you can accomplish this with the template property on the index that requires numeric keys. Define the attribute used in the composite template as type "number", and then create a template string with only the attribute's name.

For example, this model defines both the Partition and Sort Key as numeric:

const schema = {
  model: {
    entity: "numeric",
    service: "example",
    version: "1"
  },
  attributes: {
    number1: {
      type: "number" // defined as number
    },
    number2: {
      type: "number"  // defined as number
    }
  },
  indexes: {
    record: {
      pk: {
        field: "pk",
        template: "${number1}" // will build PK as numeric value 
      },
      sk: {
        field: "sk",
        template: "${number2}" // will build SK as numeric value
      }
    }
  }
}

Try it out!

Index Casing

DynamoDB is a case-sensitive data store, and therefore it is common to convert the casing of keys to uppercase or lowercase prior to saving, updating, or querying data to your table. ElectroDB, by default, will lowercase all keys when preparing query parameters. For those who are using ElectroDB with an existing dataset, have preferences on upper or lowercase, or wish to not convert case at all, this can be configured on an index key field basis.

In the example below, we are configuring the casing ElectroDB will use individually for the Partition Key and Sort Key on the GSI "gis1". For the index's PK, mapped to gsi1pk, we ElectroDB will convert this key to uppercase prior to its use in queries. For the index's SK, mapped to gsi1pk, we ElectroDB will not convert the case of this key prior to its use in queries.

{
  indexes: {
    myIndex: {
      index: "gsi1",
      pk: {
        field: "gsi1pk",
        casing: "upper", // Acct_0120 -> ACCT_0120
        composite: ["organizationId"]
      },
      sk: {
        field: "gsi1sk",
        casing: "none", // Acct_0120 -> Acct_0120 
        composite: ["accountId"]
      }
    }
  }
}

Try it out!

NOTE: Casing is a very important decision when modeling your data in DynamoDB. While choosing upper/lower is largely a personal preference, once you have begun loading records in your table it can be difficult to change your casing after the fact. Unless you have good reason, allowing for mixed case keys can make querying data difficult because it will require database consumers to always have a knowledge of their data's case.

Casing Option Effect
default The default for keys is lowercase, or lower
lower Will convert the key to lowercase prior it its use
upper Will convert the key to uppercase prior it its use
none Will not perform any casing changes when building keys

Facets

As of version 0.11.1, "Facets" have been renamed to "Composite Attributes", and all documentation has been updated to reflect that change.

Composite Attributes

A Composite Attribute is a segment of a key based on one