Skip to content

Entities

An entity is a distinct, identifiable object or concept that can have data stored about it. Entities are the primary building blocks of your data model.

Example

If you're modeling a bookstore, you might define entities such as:

  • book — represents an item in your inventory
  • author — represents a person who wrote a book
  • customer — represents someone who buys books

Defining an entity

Each entity is defined using createEntityConfig with a Zod schema:

ts
import { createEntityConfig } from 'monorise/base';
import { z } from 'zod/v4';

const baseSchema = z
  .object({
    name: z.string().min(1),
    email: z.string().email(),
    role: z.enum(['admin', 'member']),
  })
  .partial();

const createSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

const config = createEntityConfig({
  name: 'user',
  displayName: 'User',
  baseSchema,
  createSchema,
  searchableFields: ['name', 'email'],
  uniqueFields: ['email'],
});

export default config;

Configuration fields

FieldTypeRequiredDescription
namestringYesUnique kebab-case identifier (e.g., 'user', 'learning-activity')
displayNamestringYesHuman-readable name
baseSchemaz.ZodObjectYesAll possible fields (.partial() for updates)
createSchemaz.ZodObjectNoMinimum required fields for creation
searchableFieldsstring[]NoFields indexed for text search
uniqueFieldsstring[]NoFields that must be unique per entity type
mutualobjectNoMutual relationship configuration (see Mutuals)
tagsarrayNoTag access patterns (see Tags)
adjustmentConditionsobjectNoNamed conditions for adjustEntity — enforces preconditions on numeric adjustments
updateConditionsobjectNoNamed conditions for editEntity — enforces preconditions on updates
allowLegacyWherebooleanNoOpt in to accepting raw $where on editEntity (deprecated, disabled by default — see Conditional updates)
effectfunctionNoApplies a final Zod refinement to the merged entity schema
ttlobjectNoDynamoDB TTL config — see TTL below

Conditional writes

Conditions are named, server-owned preconditions. The client sends only a condition name; Monorise resolves the condition to a DynamoDB condition expression and enforces it atomically.

ts
const config = createEntityConfig({
  name: 'wallet',
  displayName: 'Wallet',
  baseSchema: z.object({
    balance: z.number(),
    status: z.enum(['pending', 'active', 'archived']),
  }).partial(),
  adjustmentConditions: {
    withdraw: (_data, adjustments) => ({
      balance: { $gte: Math.abs(adjustments.balance ?? 0) },
    }),
  },
  updateConditions: {
    activate: { status: { $eq: 'pending' } },
    archive: (_data) => ({ status: { $ne: 'archived' } }),
  },
});

adjustmentConditions receives (data, adjustments) and requires every adjustEntity call to name a condition when the config is present:

ts
await adjustEntity(Entity.WALLET, walletId, { balance: -500 }, {
  condition: 'withdraw',
});

updateConditions receives (data) and is optional for editEntity calls:

ts
await editEntity(Entity.WALLET, walletId, {
  status: 'active',
  $condition: 'activate',
});

If a named condition is unknown, or the entity has no matching condition config, the request is rejected. A condition that does not hold returns a conflict. Supported operators are $eq, $ne, $gt, $lt, $gte, $lte, $exists, and $beginsWith.

Legacy conditions

adjustmentConstraints and raw $where are deprecated compatibility mechanisms. $where is disabled by default; prefer named adjustmentConditions and updateConditions for all new code.

Advanced validation

Use effect when validation depends on the final merged entity schema rather than one individual field:

ts
const config = createEntityConfig({
  name: 'invite',
  displayName: 'Invite',
  baseSchema,
  effect: (schema) => schema.refine(
    (data) => !data.expiresAt || !data.createdAt || data.expiresAt > data.createdAt,
    'expiresAt must be after createdAt',
  ),
});

Unique fields

Fields listed in uniqueFields are enforced at the database level. If you try to create an entity with a duplicate unique field value, the API returns an error.

ts
const config = createEntityConfig({
  name: 'user',
  displayName: 'User',
  baseSchema,
  uniqueFields: ['email'],
});

You can also query entities by unique field:

GET /core/entity/user/unique/email/alice@example.com

Or using the React hook:

ts
const { entity } = useEntityByUniqueField(Entity.USER, 'email', 'alice@example.com');

Searchable fields

Fields listed in searchableFields are indexed for text search. You can search via the query parameter:

GET /core/entity/user?query=alice

Or using the React hook:

ts
const { entities, searchField } = useEntities(Entity.USER);

// Bind to an input
<input {...searchField} placeholder="Search users..." />

TTL (time-to-live)

Use ttl.processor to have DynamoDB automatically delete an entity once it expires. The processor returns the expiry as epoch seconds or a Date — return undefined for no expiry.

ts
const config = createEntityConfig({
  name: 'session',
  displayName: 'Session',
  baseSchema,
  ttl: {
    // fixed 30-day TTL from creation/each update
    processor: () => Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
  },
});

TTL can also be data-driven, computed from the entity's own fields:

ts
const config = createEntityConfig({
  name: 'invite',
  displayName: 'Invite',
  baseSchema,
  ttl: {
    processor: (entity) => {
      return entity.data.expiresOn
        ? new Date(entity.data.expiresOn)
        : undefined;
    },
  },
});

processor is called again on every update/upsert, using the entity's data as it will be after the update is applied — so a sliding-expiration TTL (e.g. extending a session on activity) works out of the box. If processor returns undefined on an update, any existing expiresAt is left untouched rather than cleared.

Internally, this always writes to an expiresAt attribute — the same attribute name the DynamoDB table's TTL is configured on (see SST SDK), so there's nothing else to wire up.

Transactional writes

Multiple entity operations can be executed atomically using the transaction API. All operations succeed or all fail — no partial writes.

ts
import { coreService, transactional } from 'monorise/react';

await coreService.transaction([
  transactional.createEntity('order', { ... }),
  transactional.adjustEntity('wallet', '...', { balance: -100, $condition: 'withdraw' }),
]);

Supported operations: createEntity, updateEntity, adjustEntity, deleteEntity. Conditions from adjustmentConditions and updateConditions are supported within transactions. Events are published only after the transaction commits.

Data layout

In DynamoDB, entities use these access patterns:

PatternKey structure
Entity metadataPK = <entityType>#<entityId>, SK = #METADATA#
Entity listPK = LIST#<entityType>, SK = <entityType>#<entityId>
Unique fieldsPK = UNIQUE#<field>#<value>, SK = <entityType>

Released under the MIT License.