@nestarc/data-subject
v0.1.0
Published
DPA-ready GDPR/CCPA toolkit for NestJS + Prisma. Entity registry, export/erase lifecycle, legal retention, outbox fan-out.
Readme
@nestarc/data-subject
@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
Current Scope
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.
Installation
npm install @nestarc/data-subjectPeer dependencies used by this package:
@nestjs/common@nestjs/corereflect-metadatarxjs@prisma/clientif you usefromPrisma(...)
Quick Start
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();Policy Model
Policies are registered per entity and compiled before execution.
delete
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
anonymize
fields: {
email: { strategy: 'anonymize', replacement: '[REDACTED]' },
}- replacements must be static
- function replacements are rejected during policy compilation
retain
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
Mixed Strategies
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.
Export Behavior
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
Erase Behavior
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
NestJS Integration
DataSubjectModule.forRoot(...) accepts:
requestStorageartifactStorageslaDaysstrictLegalBasisentitiespublishOutboxpublishAuditrunInTransaction
The module exports:
DataSubjectServiceDATA_SUBJECT_REGISTRY
Public API
The package currently exports:
DataSubjectServiceDataSubjectModuleRegistrycompilePolicyvalidateLegalBasisfromPrismaInMemoryRequestStorageInMemoryArtifactStorage- all public types from
src/types.ts - typed errors from
src/errors.ts
Events and Hooks
Outbox Hook
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.
Audit Hook
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.
Typed Errors
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.
Transaction Boundaries
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.
Practical Limitations
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
Development
npm test
npm run buildCI and Release
GitHub 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
Related Docs
License
MIT
