@nestarc/data-subject is a small NestJS-oriented toolkit for handling data-subject export and erasure requests against subject-scoped data.
Today the package ships:
- a programmatic entity registry
- a
DataSubjectServiceforexport,erase, and request lookup - a
DataSubjectModule.forRoot(...)integration for NestJS - a lightweight Prisma adapter built on
findMany,deleteMany, andupdateMany - in-memory request and artifact stores for tests and local development
- typed policy validation and typed runtime errors
Package version: 0.1.0
This repository currently focuses on the execution core. It does not currently ship:
- decorators or automatic entity discovery
- a CLI or schema linter
- persistent request storage adapters
- persistent artifact storage adapters beyond the in-memory implementation
- schema-aware Prisma field deletion beyond
nullassignment
If you need database-specific behavior, you can plug in your own EntityExecutor, RequestStorage, or ArtifactStorage.
npm install @nestarc/data-subjectPeer dependencies used by this package:
@nestjs/common@nestjs/corereflect-metadatarxjs@prisma/clientif you usefromPrisma(...)
import { Module } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import {
DataSubjectModule,
InMemoryArtifactStorage,
InMemoryRequestStorage,
fromPrisma,
} from '@nestarc/data-subject';
const prisma = new PrismaClient();
@Module({
imports: [
DataSubjectModule.forRoot({
requestStorage: new InMemoryRequestStorage(),
artifactStorage: new InMemoryArtifactStorage(),
slaDays: 30,
strictLegalBasis: true,
entities: [
{
policy: {
entityName: 'User',
subjectField: 'userId',
rowLevel: 'delete-row',
fields: {
email: 'delete',
name: 'delete',
},
},
executor: fromPrisma({
delegate: prisma.user,
subjectField: 'userId',
tenantField: 'tenantId',
}),
},
{
policy: {
entityName: 'Invoice',
subjectField: 'customerId',
fields: {
customerName: {
strategy: 'retain',
legalBasis: 'tax:KR-basic-law-sec85',
until: '+7y',
},
amount: {
strategy: 'retain',
legalBasis: 'tax:KR-basic-law-sec85',
},
customerEmail: {
strategy: 'anonymize',
replacement: '[REDACTED]',
},
},
},
executor: fromPrisma({
delegate: prisma.invoice,
subjectField: 'customerId',
tenantField: 'tenantId',
}),
},
],
publishOutbox: async (type, payload) => {
// forward to your outbox publisher
},
publishAudit: async (event, data) => {
// optional hook
},
}),
],
})
export class AppModule {}Usage:
const exportRequest = await dataSubject.export('user_123', 'tenant_abc');
const eraseRequest = await dataSubject.erase('user_123', 'tenant_abc');
const sameRequest = await dataSubject.getRequest(exportRequest.id);
const tenantRequests = await dataSubject.listByTenant('tenant_abc');
const overdue = await dataSubject.listOverdue();Policies are registered per entity and compiled before execution.
fields: {
email: 'delete',
}- shorthand
'delete'is normalized to{ strategy: 'delete' } - entity
rowLeveldefaults to'delete-fields' - with the default Prisma adapter:
'delete-row'callsdeleteMany'delete-fields'callsupdateManyand writesnullinto the configured delete fields
fields: {
email: { strategy: 'anonymize', replacement: '[REDACTED]' },
}- replacements must be static
- function replacements are rejected during policy compilation
fields: {
amount: {
strategy: 'retain',
legalBasis: 'tax:KR-basic-law-sec85',
until: '+7y',
},
}legalBasisis requiredstrictLegalBasis: trueenablesscheme:referencevalidationpseudonymizeis part of the type model, but this package does not perform pseudonymization by itself
When an entity mixes delete, anonymize, and retain, execution is intentionally conservative:
retainfields are preserved- delete fields are downgraded to field-level updates instead of row deletion
- mixed entities are reported as
strategy: 'mixed'in erase stats - retained fields are recorded in
stats.retained
This prevents retain fields from being dropped just because some other fields on the same row are deletable.
DataSubjectService.export(subjectId, tenantId) does the following:
- creates a request record
- reads matching rows from every registered entity
- writes one JSON file per entity into a ZIP archive
- stores the ZIP through
ArtifactStorage.put(...) - records:
artifactHashas a SHA-256 digest of the ZIP bytesartifactUrlreturned by the artifact storagestats.entities[]withstrategy: 'export'
Current export artifact shape:
- key:
<requestId>.zip - contents:
<EntityName>.jsonfiles
DataSubjectService.erase(subjectId, tenantId) does the following:
- creates a request record
- publishes
data_subject.erasure_requested - executes each registered entity according to its compiled policy
- records:
stats.entities[]stats.retained[]stats.verificationResidual[]artifactHashas a SHA-256 digest of the erase report JSON
Important details:
- erase uses
ArtifactStoragefor exports, but not for erase reports - erase verification currently only fails on residual rows after
delete-row - field-level delete and anonymize operations keep rows in place by design
DataSubjectModule.forRoot(...) accepts:
requestStorageartifactStorageslaDaysstrictLegalBasisentitiespublishOutboxpublishAuditrunInTransaction
The module exports:
DataSubjectServiceDATA_SUBJECT_REGISTRY
The package currently exports:
DataSubjectServiceDataSubjectModuleRegistrycompilePolicyvalidateLegalBasisfromPrismaInMemoryRequestStorageInMemoryArtifactStorage- all public types from
src/types.ts - typed errors from
src/errors.ts
If publishOutbox is provided, the built-in service emits:
data_subject.request_createddata_subject.erasure_requesteddata_subject.request_completeddata_subject.request_failed
request_completed and request_failed are emitted for both export and erase requests. erasure_requested is erase-only.
If publishAudit is provided, the built-in service currently emits:
data_subject.request_created
No additional audit lifecycle events are emitted by the current implementation.
The package exposes DataSubjectError with stable error codes.
Currently used codes include:
dsr_invalid_policydsr_anonymize_dynamic_replacementdsr_verification_faileddsr_entity_already_registereddsr_request_conflictdsr_request_not_found
Some additional codes exist in the public enum for future or adapter-specific use.
runInTransaction is an integration hook, not an automatic rollback guarantee.
new DataSubjectService({
// ...
runInTransaction: async (work) => myUnitOfWork.run(work),
});Use it when your erase flow can run inside a real unit-of-work that also covers:
- the entity executors
- request storage writes
- outbox publishing
If those components do not participate in the same transaction boundary, rollback remains best-effort.
The current implementation is intentionally small. A few things are important to know up front:
fromPrisma(...)only depends onfindMany,deleteMany, andupdateMany- default Prisma field deletion writes
null; it does not inspect schema nullability - request states include
validating, but the built-in service currently transitions throughcreated -> processing -> completed|failed - there is no built-in subject existence check before export or erase
- only in-memory request and artifact adapters are included in this repository
npm test
npm run buildGitHub Actions is configured with two workflows:
CI: runsnpm ci,npm run lint,npm test -- --runInBand, andnpm run buildon pushes, pull requests, and manual runsRelease: runs the same validation suite and then publishes to npm when a GitHub Release is published
Release expectations:
- configure repository secret
NPM_TOKEN - publish a GitHub Release from a tag that matches
v<package.json version> - prerelease versions publish with npm dist-tag
next - stable versions such as
0.1.0publish with npm dist-taglatest
MIT