Skip to content

Action history API ​

Import path: @sdcorejs/nestjs/features

Action history stores tenant-scoped before/after snapshots for resource changes. It can be called directly or registered as the history recorder used by BaseRepository({ logHistory: true }).

Exports ​

ExportKindPurpose
ActionHistoryentity classaction-history PostgreSQL row
ActionHistoryTypeenumCREATE, UPDATE, DELETE
ActionHistorySaveReq<T>interfaceManual write request
ActionHistoryDTO<T>interfaceSerialized row
ActionHistoryActor, ActionHistoryActorResolvertypesPer-request actor mapping
ACTION_HISTORY_ACTOR_RESOLVERvalueActor resolver DI token
ActionHistoryQuery, ActionHistoryPage<T>interfacesBounded resource query/result
ActionHistoryAuthorizationRequest, ActionHistoryAuthorizationPolicytypesMandatory read policy input/callback
ActionHistorySnapshotRedactortypeOptional pre-redaction transform
ActionHistoryResourceTenantRequest, ActionHistoryResourceTenantResolvertypesPersisted resource scope to tenant mapping
ActionHistorySecurityOptionsinterfaceRead, redaction, tenant, page and retention policy
ACTION_HISTORY_SECURITY_OPTIONSvalueSecurity policy DI token
ActionHistoryServiceclasscreate, record, and all API
ActionHistoryModule, ActionHistoryModuleOptionsclass/typeFeature and recorder registration
ActionHistoryControllerclassOptional authenticated read endpoint
MissingActionHistoryTenantErrorclassManual write has no trusted tenant
MissingActionHistoryResourceTenantErrorclassPersisted scope cannot map safely to one tenant
ActionHistorySnapshotLimitErrorclassSnapshot exceeds defensive limits
ActionHistoryUnsafeSnapshotErrorclassSnapshot contains accessors or prototype-sensitive properties

Entity and module setup ​

ts
TypeOrmModule.forRoot({
  type: 'postgres',
  // ...
  entities: [ActionHistory, Product],
});

ActionHistoryModule.forRoot({
  authorizeRead: ({ context, tenantCode, table, tableId }) =>
    context.tenant === tenantCode &&
    context.permissions?.includes(`${table}:history:read`) === true,
  resolveActor: (ctx) => ({
    userId: ctx.userId,
    username: ctx.getCustom<string>('username'),
    fullName: ctx.getCustom<string>('fullName'),
  }),
  redactFields: ['payment.cardNumber'],
  maxPageSize: 100,
  retentionDays: 365,
});

Add ActionHistory to the datasource. The entity uses a PostgreSQL enum, UUIDs and JSONB snapshots. global and registerAsHistoryRecorder both default to true. retentionDays is an operations hint only; the library never deletes history automatically.

Automatic repository history ​

ts
export class ProductRepository extends BaseRepository<Product> {
  constructor(dataSource: DataSource) {
    super(Product, dataSource, { logHistory: true });
  }
}

Repository create/update/hard-delete emits a HistoryEntry in the same transaction. The stable resource type is TypeORM EntityMetadata.tablePath, including schema. For scoped entities, tenant attribution comes from scope values selected from the persisted row—not DTO/request input.

The default mapping reads resourceScope.tenantCode. If an entity uses another tenant property, configure a synchronous resolveResourceTenant:

ts
resolveResourceTenant: ({ resourceScope }) =>
  typeof resourceScope.organizationId === 'string'
    ? resourceScope.organizationId
    : undefined,

If the default tenantCode and custom resolver both produce different values, the write fails. An unmappable scoped resource also fails before commit.

Manual writes ​

ts
await history.create({
  table: 'public.product',
  tableId: product.id,
  type: ActionHistoryType.UPDATE,
  fromData: before,
  toData: after,
  note: 'Price corrected',
});

Manual create takes tenant from ContextService.tenant, falling back to ContextService.custom.tenantCode, and throws MissingActionHistoryTenantError if neither is a nonblank string. An optional QueryRunner writes through that transaction.

record(entry: HistoryEntry) is the IHistoryRecorder method used by repositories.

Read API ​

ts
const page = await history.all({
  table: 'public.product',
  tableId: product.id,
  pageNumber: 0,
  pageSize: 50,
});

Pages are zero-based. The default configured maximum is 100; finite configuration is clamped to 1..200. Request size is clamped to that maximum and the database offset is capped at 100,000. Results sort newest first and return { items, total }.

Read access is denied unless authorizeRead returns exactly true. Missing tenant, invalid query, policy deny/exception and missing data all produce 404 core.history.not-found, preventing resource enumeration. The SQL predicate includes tenant, table and table ID.

Snapshot redaction and limits ​

The optional redactSnapshot(snapshot, context) transform runs first. The mandatory recursive redactor then replaces configured paths and common secret-like field names (password, secret, token, authorization, API/private/access keys and credentials) with [REDACTED]. Matching is case-insensitive and recognizes compound key names. Cycles are redacted; Date becomes ISO text.

Fixed defensive ceilings are 32 levels, 10,000 nodes and 1 MiB of counted UTF-8 snapshot/path text. Exceeding a ceiling throws ActionHistorySnapshotLimitError before persistence. Do not use a custom transform to reintroduce secrets; mandatory redaction always runs afterward. Enumerable accessors, symbol keys and prototype-sensitive keys such as __proto__, prototype or constructor throw ActionHistoryUnsafeSnapshotError before a getter can run or the snapshot can be persisted.

Optional controller ​

ActionHistoryController is not auto-registered. Add it to an application module:

text
GET /action-history/:table/:tableId?pageNumber=0&pageSize=100

It applies AuthGuard, wraps the page in ApiResponse.ok, and still relies on service-level authorizeRead. If your tablePath contains characters requiring URL escaping, encode the route segment or provide an application-specific controller.

Security notes ​

  • Read policy is mandatory; omission means deny all.
  • Snapshot history is sensitive. Apply field redaction, database access controls and explicit retention outside the library.
  • The row is an audit trail, not cryptographic tamper evidence. Use an append-only external sink if you require immutability guarantees.

Released under the MIT License.