@caverar/ts-entity

中文文档
1.4.0发布于 2026/11/06GPL-3.0-onlyTypeScriptES Module零依赖近 30 天下载 2617

Type-safe domain entities with schema validation, state transitions, and serialization.

原始使用文档
文档目录11 节
  1. Installation
  2. Quick start
  3. Choosing an output
  4. Persistence and optimistic concurrency
  5. Creating a new entity
  6. Mutating an entity
  7. Describing persistence changes
  8. Restoring after persistence
  9. Publishing
  10. One-time setup
  11. License

@caverar/ts-entity

Type-safe domain entities for TypeScript with schema validation, explicit state transitions, and serialization.

Installation

npm install @caverar/ts-entity zod

TypeScript ^6.0.3 is a peer dependency. The package uses the Standard Schema interface, so you can adapt another compatible validation library instead of Zod.

Quick start

import * as z from 'zod'
import {
  EntityFactory,
  type EntityMetaStatusVariants,
  type EntityFlatSnapshot,
  type EntitySelf,
} from '@caverar/ts-entity'

const factory = new EntityFactory({
  dateFormat: 'Date',
  id: {
    idSchema: z.uuid(),
    idGenerator: () => crypto.randomUUID(),
  },
  fieldsSchemasGenerator: (schemas) =>
    Object.assign({}, ...(schemas as any).map((schema: z.ZodObject) => schema.shape)),
  mergeSchemaGenerator: (schemas) => z.union(schemas as any),
})

const UserBase = factory.createAbstractEntity({
  draft: {
    schema: z.object({
      email: z.email(),
      name: z.string().min(1),
    }),
    mutatesTo: ['draft', 'active'],
  },
  active: {
    schema: z.object({
      email: z.email(),
      name: z.string().min(1),
      activatedAt: z.date(),
    }),
    mutatesTo: [],
  },
})

class User extends UserBase {
  activate(this: EntitySelf<User, 'draft'>) {
    return this.mutateEntity({ activatedAt: new Date() }, 'active')
  }
}

const result = User.createEntity({ email: '[email protected]', name: 'Ada' }, 'draft')

if (!result.isOk) {
  throw new Error('Invalid user data')
}

const user = result.value
console.log(user.id, user.data.email)

// Use EntityMetaStatusVariants for a value whose current domain state varies.
// `metaStatus` narrows the union to its state-specific data and methods.
function inspectUser(entity: EntityMetaStatusVariants<User>) {
  if (entity.metaStatus === 'draft') {
    console.log(entity.data.email)
  } else {
    console.log(entity.data.activatedAt)
  }
}

inspectUser(user)

// A snapshot is also assignable to the merged shape accepted by restoreEntity().
const flatSnapshot: EntityFlatSnapshot<User> = user.toSnapshot()

const activation = user.activate()
if (activation.isOk) {
  console.log(activation.value.metaStatus) // 'active'
}

toSnapshot() preserves the current state's shape and returns a { meta, data } union discriminated by metaStatus. It accepts { target: 'json' } to serialize values such as dates and bigints for JSON storage. See Choosing an output for when to use it versus the other serialization methods.

Choosing an output

Entities expose three groups of APIs. Pick by who consumes the result.

Runtime serialization (instance methods)

MethodConsumerShapeversionBrands
toSnapshot()Simple repositories; the inverse of restoreEntity(){ meta, data }, discriminated by metaStatusincludedremoved
toPersistenceChanges()Repositories that write only what changed{ persistenceStatus, meta, set, unset }includedremoved
toDTO()Use case input/output, other bounded contexts, entry pointsFlat object: meta and data at one levelnever exposedremoved; keepBrands: true keeps them (types only)

All three accept { target: 'json' }. toDTO() also accepts omit (any key except metaStatus, which keeps unions discriminable) and never leaks the entity itself.

Construction and reconstruction (static methods)

MethodPurpose
createEntity(data, metaStatus, meta?)Starts a new lifecycle (persistenceStatus: 'new', version: 1)
restoreEntity({ meta, data })Rebuilds an unchanged baseline from a stored snapshot; data uses the merged shape

Type helpers

TypeDescribes
EntitySnapshot<E, Target, States>Return type of toSnapshot()
EntityFlatSnapshot<E, Target, States>Snapshot with every state merged (state-specific fields optional); the shape of restoreEntity() input and database projections
EntityPersistenceChanges<...>Return type of toPersistenceChanges()
EntityDTO<E, States, Omitted, Target>Return type of toDTO(), unbranded; a union discriminated by metaStatus
EntityBrandedDTO<...>Same as EntityDTO, keeping brands (toDTO({ keepBrands: true }))
EntityRawDTO<...>EntityDTO flattened into one object across states, for manual DTO/type construction
EntityPickDTO<E, States, Keys>Picked keys of the flat DTO, for use case inputs
type AccountResponse = EntityDTO<Account, 'active' | 'frozen', 'createdAt', 'json'>
const response: AccountResponse = account.toDTO({ target: 'json', omit: ['createdAt'] })

Persistence and optimistic concurrency

Entities expose persistenceStatus (new, updated, or unchanged) and a numeric version. Together they let a repository choose an insert, an optimistic update, or no write. These values describe the in-memory persistence lifecycle; they do not perform database operations themselves.

Creating a new entity

User.createEntity(data, state) validates the initial data and returns a new entity with persistenceStatus: 'new' and version: 1. Further mutations before the first insert keep it new at version 1. Persist it with INSERT, including version 1; do not treat it as an update. Creation alone does not save it.

For virtual entities that are not owned by a single database, pass optional metadata as a third argument: User.createEntity(data, state, { id, createdAt, updatedAt }). Every key is optional. A missing id is generated; a provided id is validated against the id schema (invalid ids return INVALID_DATA). Missing dates default to now, and updatedAt defaults to createdAt.

Mutating an entity

mutateEntity() is used by domain methods and returns a new immutable entity; the source instance is unchanged. After a successful mutation:

  • A new entity stays new at version 1, regardless of how many times it is mutated before insert.
  • The first mutation of an unchanged entity marks the result updated and increments the version once.
  • Further mutations while it remains updated keep that version. This lets several runtime changes form one edit cycle and one database update.
  • A schema validation failure returns INVALID_DATA; it does not produce a mutated entity.

For example, if the loaded entity is version 4, its first mutation produces version 5. More mutations in that cycle remain version 5. For an optimistic update, use the previous version as the compare condition and the entity's version as the value to write:

UPDATE users
SET name = :name, version = :newVersion
WHERE id = :id AND version = :expectedVersion;

For an entity at version 5, set newVersion = 5 and expectedVersion = 4 (equivalently, newVersion = expectedVersion + 1).

Treat an update that affects zero rows as a concurrency conflict: another writer changed or removed the row. Do not mark the entity clean or retry by silently replacing its version; reload the latest snapshot and resolve the conflict according to the application's rules. An entity with persistenceStatus: 'unchanged' can be skipped by the repository.

Describing persistence changes

toPersistenceChanges() returns a database-agnostic descriptor containing the current metadata and the data a repository may choose to write. It does not execute a query or require a repository to use a particular persistence strategy:

  • new: set contains all current entity data, suitable as the values for an insert.
  • updated: set contains touched fields with their latest values; unset contains fields removed from the current data, for example when a state transition removes a state-specific field.
  • unchanged: both set and unset are empty.

Touched fields accumulate since restoreEntity(). If a field changes and is later reverted, it remains in set with its latest value. Repositories can use this descriptor to avoid writing unmodified fields, or ignore it and persist a complete snapshot instead. The library does not map entity fields to database columns or decide how a database represents an unset field. Changes are reported by top-level entity field; a nested object is supplied as the value of its top-level field. Like snapshots, the method accepts { target: 'json' } to serialize dates and bigints for JSON-based storage.

For optimistic concurrency, a repository can include meta.version in its update condition and write that version together with the changes. For example, when the descriptor has version 5, an update can require the stored version to be 4 and set it to 5. This prevents overwriting a row changed by another writer, provided every writer follows the same versioning rule. A zero-row update is a conflict, not a successful save.

Restoring after persistence

User.restoreEntity(snapshot) is static and reconstructs an entity from a persisted snapshot. It validates the id, positive safe-integer version, and data against the schema selected by meta.metaStatus. The metaStatus field may be omitted when the database query does not return it; in that case, the data is checked against state schemas in declaration order and the first matching state is inferred. If state schemas overlap, this order determines the inferred state, so use distinctive required/discriminant fields when omitting the status. When metaStatus is provided, the result keeps that specific state type; when omitted, it has the union of possible states. A successful result has persistenceStatus: 'unchanged' and preserves the snapshot's version; restoration itself neither saves nor increments that version.

Call it only after the insert/update succeeds, preferably with the row returned by the database. That restored instance is the baseline for the next edit cycle. After saving version 5, restoring the stored row yields an unchanged version-5 entity; its next successful mutation begins version 6. If the write failed due to a version conflict, keep the conflict outcome rather than restoring the attempted snapshot as though it had been persisted.

The restore input is intentionally not a discriminated union: data uses a merged shape across all states. Fields required in every state are required at compile time; state-specific fields are optional so ordinary database result types remain usable. Runtime validation checks the complete schema for the explicit meta.metaStatus, or infers a state and validates against that state's schema when the status is omitted. In either case, the selected state's required fields must be present. The metaStatus type is restricted to the entity's declared states; invalid runtime input returns INVALID_DATA rather than throwing. The persistence status is not serialized: it is determined by the operation (createEntity makes new; restoreEntity makes unchanged).

Snapshots include meta.version, but not persistenceStatus. toSnapshot() preserves the state-specific data shape and is assignable to EntityFlatSnapshot, the merged shape that restoreEntity() accepts. JSON-target snapshots convert dates and bigints to strings; convert those values back to the configured native types before passing a snapshot to restoreEntity(). Supporting an explicit native-versus-JSON restore mode, with decoding of dates, Temporal values, and bigints, remains a planned improvement.

The schema callbacks above adapt Zod object schemas to the factory. If your states use different schemas or you use a different validation library, provide equivalent callbacks for its Standard Schema-compatible schemas.

Publishing

The package is configured for public npm publication. Releases after the initial version are published by GitHub Actions when a v* tag matching the version in package.json is pushed. The workflow runs type checks, lint, tests, build, and publint before publishing with npm provenance.

One-time setup

  1. Confirm that your npm account owns the @caverar scope and that @caverar/ts-entity is available.

  2. For the first release, publish version 1.0.0 from this checkout. npm needs the package to exist before you can configure its trusted publisher:

    bun install --frozen-lockfile
    npm login
    npm publish --access public
    
  3. On npmjs.com, open @caverar/ts-entity → Settings → Trusted Publisher, choose GitHub Actions, and set:

    • Owner: caverar
    • Repository: ts-entity
    • Workflow: publish.yml
    • Environment: leave blank
  4. Push a matching tag for the next unpublished version (for example, after updating the package to 1.0.1, push v1.0.1). Do not push a v1.0.0 tag after publishing 1.0.0 manually; npm does not allow publishing the same version twice.

No npm token or GitHub secret is required for releases after trusted publishing is enabled. npm validates a new trusted publisher configuration on its first successful publish, so push a release tag within two days of creating the configuration.

License

GPL-3.0-only

版本与发布记录

@caverar/ts-entity 共发布 16 个版本,首次发布于 2026/10/20;过去 12 个月发布了 16 个版本。

版本发布日期距今
1.4.0latest2026/11/063 天前
1.3.02026/11/044 天前
1.2.02026/11/045 天前
1.1.12026/11/035 天前
1.1.02026/11/036 天前
1.0.92026/11/036 天前

关于 @caverar/ts-entity 的常见问题

@caverar/ts-entity 是什么?

Type-safe domain entities with schema validation, state transitions, and serialization.

如何安装 @caverar/ts-entity?

在项目目录运行 npm install @caverar/ts-entity,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:typescript(^6.0.3)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@caverar/ts-entity 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@caverar/ts-entity 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@caverar/ts-entity 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@caverar/ts-entity 可以商用吗?

@caverar/ts-entity 使用 GPL-3.0-only 许可。强 Copyleft:分发包含该代码的软件时,整个作品需以 GPL-3.0 兼容许可开源,并附带专利与反锁定条款。仅内部使用不触发此义务。以上为概括,不构成法律意见,请以许可原文为准。

@caverar/ts-entity 的下载量有多少?

根据 npm 官方下载统计,@caverar/ts-entity 在 2026/10/09 – 2026/11/07 这 30 天内被下载 2,617 次,日均约 87 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@caverar/ts-entity 还在维护吗?

最新版本 1.4.0 发布于 2026/11/06(3 天前);至今共发布 16 个版本,其中过去 12 个月发布了 16 个;首次发布于 2026/10/20。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。