@nestarc/data-subject 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -18
- package/dist/artifacts.d.ts +3 -0
- package/dist/artifacts.js +21 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +155 -0
- package/dist/data-subject.service.js +84 -13
- package/dist/erase-runner.d.ts +6 -2
- package/dist/erase-runner.js +66 -5
- package/dist/erasure-evidence.d.ts +9 -0
- package/dist/erasure-evidence.js +18 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +6 -0
- package/dist/export-runner.js +3 -3
- package/dist/index.d.ts +8 -0
- package/dist/index.js +16 -1
- package/dist/lint/index.d.ts +3 -0
- package/dist/lint/index.js +24 -0
- package/dist/lint/lint.d.ts +4 -0
- package/dist/lint/lint.js +160 -0
- package/dist/lint/prisma-schema.d.ts +11 -0
- package/dist/lint/prisma-schema.js +43 -0
- package/dist/lint/types.d.ts +32 -0
- package/dist/lint/types.js +2 -0
- package/dist/registry-validation.d.ts +9 -0
- package/dist/registry-validation.js +37 -0
- package/dist/stable-json.d.ts +1 -0
- package/dist/stable-json.js +30 -0
- package/dist/storage/prisma-request-storage.d.ts +33 -0
- package/dist/storage/prisma-request-storage.js +156 -0
- package/dist/types.d.ts +84 -0
- package/docs/code-review-src.md +293 -0
- package/docs/compliance.md +178 -0
- package/docs/data-subject-0.2.0-feature-proposal.md +565 -0
- package/docs/data-subject-0.2.0-spec.md +679 -0
- package/docs/prd.md +164 -0
- package/docs/spec.md +282 -0
- package/package.json +24 -1
- package/prisma/schema.example.prisma +31 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DataSubjectRequest, RequestState } from '../types';
|
|
2
|
+
import type { RequestStorage } from './request-storage.interface';
|
|
3
|
+
export interface PrismaDataSubjectRequestDelegate {
|
|
4
|
+
create(args: {
|
|
5
|
+
data: Record<string, unknown>;
|
|
6
|
+
}): Promise<unknown>;
|
|
7
|
+
update(args: {
|
|
8
|
+
where: {
|
|
9
|
+
id: string;
|
|
10
|
+
};
|
|
11
|
+
data: Record<string, unknown>;
|
|
12
|
+
}): Promise<unknown>;
|
|
13
|
+
findUnique(args: {
|
|
14
|
+
where: {
|
|
15
|
+
id: string;
|
|
16
|
+
};
|
|
17
|
+
}): Promise<unknown | null>;
|
|
18
|
+
findMany(args: Record<string, unknown>): Promise<unknown[]>;
|
|
19
|
+
}
|
|
20
|
+
export interface PrismaRequestStorageOptions {
|
|
21
|
+
delegate: PrismaDataSubjectRequestDelegate;
|
|
22
|
+
}
|
|
23
|
+
export declare class PrismaRequestStorage implements RequestStorage {
|
|
24
|
+
private readonly opts;
|
|
25
|
+
constructor(opts: PrismaRequestStorageOptions);
|
|
26
|
+
insert(req: DataSubjectRequest): Promise<void>;
|
|
27
|
+
update(id: string, patch: Partial<DataSubjectRequest>): Promise<void>;
|
|
28
|
+
findById(id: string): Promise<DataSubjectRequest | null>;
|
|
29
|
+
listByTenant(tenantId: string, opts?: {
|
|
30
|
+
state?: RequestState;
|
|
31
|
+
}): Promise<DataSubjectRequest[]>;
|
|
32
|
+
listOverdue(now: Date): Promise<DataSubjectRequest[]>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PrismaRequestStorage = void 0;
|
|
4
|
+
const errors_1 = require("../errors");
|
|
5
|
+
const PENDING = ['created', 'validating', 'processing'];
|
|
6
|
+
class PrismaRequestStorage {
|
|
7
|
+
opts;
|
|
8
|
+
constructor(opts) {
|
|
9
|
+
this.opts = opts;
|
|
10
|
+
}
|
|
11
|
+
async insert(req) {
|
|
12
|
+
try {
|
|
13
|
+
await this.opts.delegate.create({ data: serializeRequest(req) });
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (hasPrismaCode(error, 'P2002')) {
|
|
17
|
+
throw new errors_1.DataSubjectError(errors_1.DataSubjectErrorCode.RequestConflict, `duplicate id: ${req.id}`);
|
|
18
|
+
}
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async update(id, patch) {
|
|
23
|
+
try {
|
|
24
|
+
await this.opts.delegate.update({
|
|
25
|
+
where: { id },
|
|
26
|
+
data: serializePatch(patch),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (hasPrismaCode(error, 'P2025')) {
|
|
31
|
+
throw new errors_1.DataSubjectError(errors_1.DataSubjectErrorCode.RequestNotFound, `request ${id} not found`);
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async findById(id) {
|
|
37
|
+
const row = await this.opts.delegate.findUnique({ where: { id } });
|
|
38
|
+
return row ? deserializeRequest(row) : null;
|
|
39
|
+
}
|
|
40
|
+
async listByTenant(tenantId, opts = {}) {
|
|
41
|
+
const rows = await this.opts.delegate.findMany({
|
|
42
|
+
where: {
|
|
43
|
+
tenantId,
|
|
44
|
+
...(opts.state ? { state: opts.state } : {}),
|
|
45
|
+
},
|
|
46
|
+
orderBy: { createdAt: 'desc' },
|
|
47
|
+
});
|
|
48
|
+
return rows.map(deserializeRequest);
|
|
49
|
+
}
|
|
50
|
+
async listOverdue(now) {
|
|
51
|
+
const rows = await this.opts.delegate.findMany({
|
|
52
|
+
where: {
|
|
53
|
+
state: { in: PENDING },
|
|
54
|
+
dueAt: { lt: now },
|
|
55
|
+
},
|
|
56
|
+
orderBy: { dueAt: 'asc' },
|
|
57
|
+
});
|
|
58
|
+
return rows.map(deserializeRequest);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
exports.PrismaRequestStorage = PrismaRequestStorage;
|
|
62
|
+
function serializeRequest(req) {
|
|
63
|
+
return {
|
|
64
|
+
id: req.id,
|
|
65
|
+
tenantId: req.tenantId,
|
|
66
|
+
subjectId: req.subjectId,
|
|
67
|
+
type: req.type,
|
|
68
|
+
state: req.state,
|
|
69
|
+
createdAt: req.createdAt,
|
|
70
|
+
dueAt: req.dueAt,
|
|
71
|
+
completedAt: req.completedAt,
|
|
72
|
+
failedAt: req.failedAt,
|
|
73
|
+
failureReason: req.failureReason,
|
|
74
|
+
artifactHash: req.artifactHash,
|
|
75
|
+
artifactUrl: req.artifactUrl,
|
|
76
|
+
stats: req.stats,
|
|
77
|
+
requestedBy: req.requestedBy,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function serializePatch(patch) {
|
|
81
|
+
const data = {};
|
|
82
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
83
|
+
if (value !== undefined) {
|
|
84
|
+
data[key] = value;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return data;
|
|
88
|
+
}
|
|
89
|
+
function deserializeRequest(row) {
|
|
90
|
+
const raw = row;
|
|
91
|
+
return {
|
|
92
|
+
id: stringValue(raw.id),
|
|
93
|
+
tenantId: stringValue(raw.tenantId),
|
|
94
|
+
subjectId: stringValue(raw.subjectId),
|
|
95
|
+
type: requestTypeValue(raw.type),
|
|
96
|
+
state: requestStateValue(raw.state),
|
|
97
|
+
createdAt: dateValue(raw.createdAt),
|
|
98
|
+
dueAt: dateValue(raw.dueAt),
|
|
99
|
+
completedAt: nullableDateValue(raw.completedAt),
|
|
100
|
+
failedAt: nullableDateValue(raw.failedAt),
|
|
101
|
+
failureReason: nullableStringValue(raw.failureReason),
|
|
102
|
+
artifactHash: nullableStringValue(raw.artifactHash),
|
|
103
|
+
artifactUrl: nullableStringValue(raw.artifactUrl),
|
|
104
|
+
stats: (raw.stats ?? null),
|
|
105
|
+
requestedBy: nullableStringValue(raw.requestedBy),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function stringValue(value) {
|
|
109
|
+
if (typeof value !== 'string') {
|
|
110
|
+
throw new TypeError('expected string field in DataSubjectRequest row');
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
function nullableStringValue(value) {
|
|
115
|
+
if (value === null || value === undefined) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return stringValue(value);
|
|
119
|
+
}
|
|
120
|
+
function dateValue(value) {
|
|
121
|
+
if (value instanceof Date) {
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
if (typeof value === 'string') {
|
|
125
|
+
return new Date(value);
|
|
126
|
+
}
|
|
127
|
+
throw new TypeError('expected date field in DataSubjectRequest row');
|
|
128
|
+
}
|
|
129
|
+
function nullableDateValue(value) {
|
|
130
|
+
if (value === null || value === undefined) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return dateValue(value);
|
|
134
|
+
}
|
|
135
|
+
function requestTypeValue(value) {
|
|
136
|
+
if (value === 'export' || value === 'erase') {
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
throw new TypeError('expected request type field in DataSubjectRequest row');
|
|
140
|
+
}
|
|
141
|
+
function requestStateValue(value) {
|
|
142
|
+
if (value === 'created' ||
|
|
143
|
+
value === 'validating' ||
|
|
144
|
+
value === 'processing' ||
|
|
145
|
+
value === 'completed' ||
|
|
146
|
+
value === 'failed') {
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
throw new TypeError('expected request state field in DataSubjectRequest row');
|
|
150
|
+
}
|
|
151
|
+
function hasPrismaCode(error, code) {
|
|
152
|
+
return (typeof error === 'object' &&
|
|
153
|
+
error !== null &&
|
|
154
|
+
'code' in error &&
|
|
155
|
+
error.code === code);
|
|
156
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -59,10 +59,94 @@ export interface RequestStats {
|
|
|
59
59
|
entityName: string;
|
|
60
60
|
field: string;
|
|
61
61
|
legalBasis: string;
|
|
62
|
+
until?: string;
|
|
62
63
|
count: number;
|
|
63
64
|
}>;
|
|
64
65
|
verificationResidual?: Array<{
|
|
65
66
|
entityName: string;
|
|
66
67
|
count: number;
|
|
67
68
|
}>;
|
|
69
|
+
preScan?: Array<{
|
|
70
|
+
entityName: string;
|
|
71
|
+
count: number;
|
|
72
|
+
}>;
|
|
73
|
+
postScan?: Array<{
|
|
74
|
+
entityName: string;
|
|
75
|
+
count: number;
|
|
76
|
+
}>;
|
|
77
|
+
evidence?: {
|
|
78
|
+
schemaVersion: 'data-subject.evidence.v1';
|
|
79
|
+
artifactHash: string;
|
|
80
|
+
artifactUrl: string;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export interface ErasureEvidenceAction {
|
|
84
|
+
entityName: string;
|
|
85
|
+
strategy: Exclude<RequestEntityStrategy, 'export'>;
|
|
86
|
+
affected: number;
|
|
87
|
+
rowLevel: RowLevel;
|
|
88
|
+
deleteFields?: string[];
|
|
89
|
+
anonymizedFields?: string[];
|
|
90
|
+
retainedFields?: Array<{
|
|
91
|
+
field: string;
|
|
92
|
+
legalBasis: string;
|
|
93
|
+
until?: string;
|
|
94
|
+
count: number;
|
|
95
|
+
}>;
|
|
96
|
+
}
|
|
97
|
+
export interface ErasureEvidenceArtifact {
|
|
98
|
+
schemaVersion: 'data-subject.erasure-evidence.v1';
|
|
99
|
+
requestId: string;
|
|
100
|
+
tenantId: string;
|
|
101
|
+
requestType: 'erase';
|
|
102
|
+
generatedAt: string;
|
|
103
|
+
state: 'completed';
|
|
104
|
+
preScan: Array<{
|
|
105
|
+
entityName: string;
|
|
106
|
+
count: number;
|
|
107
|
+
}>;
|
|
108
|
+
actions: ErasureEvidenceAction[];
|
|
109
|
+
postScan: Array<{
|
|
110
|
+
entityName: string;
|
|
111
|
+
count: number;
|
|
112
|
+
}>;
|
|
113
|
+
verificationResidual: Array<{
|
|
114
|
+
entityName: string;
|
|
115
|
+
count: number;
|
|
116
|
+
}>;
|
|
117
|
+
artifactHashAlgorithm: 'sha256';
|
|
68
118
|
}
|
|
119
|
+
export type DataSubjectOutboxEvent = {
|
|
120
|
+
type: 'data_subject.request_created';
|
|
121
|
+
payload: {
|
|
122
|
+
requestId: string;
|
|
123
|
+
requestType: RequestType;
|
|
124
|
+
tenantId: string;
|
|
125
|
+
subjectId: string;
|
|
126
|
+
};
|
|
127
|
+
} | {
|
|
128
|
+
type: 'data_subject.erasure_requested';
|
|
129
|
+
payload: {
|
|
130
|
+
requestId: string;
|
|
131
|
+
tenantId: string;
|
|
132
|
+
subjectId: string;
|
|
133
|
+
requestedAt: string;
|
|
134
|
+
};
|
|
135
|
+
} | {
|
|
136
|
+
type: 'data_subject.request_completed';
|
|
137
|
+
payload: {
|
|
138
|
+
requestId: string;
|
|
139
|
+
requestType: RequestType;
|
|
140
|
+
tenantId: string;
|
|
141
|
+
artifactHash: string;
|
|
142
|
+
state: 'completed';
|
|
143
|
+
};
|
|
144
|
+
} | {
|
|
145
|
+
type: 'data_subject.request_failed';
|
|
146
|
+
payload: {
|
|
147
|
+
requestId: string;
|
|
148
|
+
requestType: RequestType;
|
|
149
|
+
tenantId: string;
|
|
150
|
+
failureReason: string;
|
|
151
|
+
};
|
|
152
|
+
};
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# src/ 코드 리뷰 및 보완 계획
|
|
2
|
+
|
|
3
|
+
리뷰 범위: `src/` 전체 (약 1,181 LOC, 테스트 포함)
|
|
4
|
+
리뷰 일자: 2026-04-15
|
|
5
|
+
관련 문서: `docs/spec.md`
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 문서 목적
|
|
10
|
+
|
|
11
|
+
이 문서는 `src/` 코드 리뷰 결과를 단순 메모가 아니라 **수정 착수용 작업 문서**로 정리한 것이다.
|
|
12
|
+
핵심 목표는 다음 세 가지다.
|
|
13
|
+
|
|
14
|
+
- v0.1 출시 전 반드시 막아야 할 컴플라이언스 결함을 식별한다.
|
|
15
|
+
- 각 결함이 어떤 spec 계약을 깨는지 연결한다.
|
|
16
|
+
- 수정 방향, 예상 변경 파일, 완료 기준, 테스트 항목을 한 번에 정리한다.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 결론 요약
|
|
21
|
+
|
|
22
|
+
현재 코드베이스에는 출시 차단 이슈가 2건 있다.
|
|
23
|
+
|
|
24
|
+
1. `delete`와 `retain`이 같은 row에서 섞일 때, 현재 로직은 row 전체를 삭제할 수 있다.
|
|
25
|
+
2. `delete-fields` 경로가 어댑터 계약에 표현되지 않아 Prisma 구현에서 사실상 no-op이다.
|
|
26
|
+
|
|
27
|
+
두 이슈 모두 "테스트가 일부 통과하더라도 법적 의무를 어길 수 있는" 종류의 결함이다.
|
|
28
|
+
따라서 v0.1에서는 아래 방향을 권장한다.
|
|
29
|
+
|
|
30
|
+
- `retain`이 하나라도 있으면 row-level delete를 금지하고 field-level update로 강등한다.
|
|
31
|
+
- `delete-fields` 의미론은 유지하되, 내부 구현은 "필드 업데이트 계획"을 어댑터에 전달하는 방식으로 재설계한다.
|
|
32
|
+
- 이번 라운드의 구현 범위는 최소 `#1`, `#2`, `#3`, `#4`까지 포함한다.
|
|
33
|
+
- `#5`, `#6`, Nits는 후속 라운드로 분리하되, 문서에는 리스크와 권고를 남긴다.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 핵심 설계 긴장
|
|
38
|
+
|
|
39
|
+
이 라이브러리의 본질적인 설계 긴장은 **삭제(delete)와 보존(retain)이 같은 row에서 공존**할 수 있다는 점이다.
|
|
40
|
+
GDPR/개인정보보호법에서 세금 영수증처럼 일부 필드는 법적 보관 의무가 있고, 다른 필드는 즉시 삭제 의무가 있을 수 있다. 이 충돌을 어떻게 해소하느냐가 컴플라이언스 정확도를 결정한다.
|
|
41
|
+
|
|
42
|
+
Prisma delegate 래핑 패턴(`findMany`/`deleteMany`/`updateMany`만 요구)은 ORM 결합도를 얕게 유지하는 좋은 포트/어댑터 경계다. 다만 현재 인터페이스에는 "필드 수준 삭제"를 안전하게 표현할 정보가 부족하다.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 출시 차단 이슈
|
|
47
|
+
|
|
48
|
+
### 1. `erase-runner.ts:105-109` — retain 필드가 포함된 row를 통째로 삭제함
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
const rowStrategy: Strategy = strategies.has('delete')
|
|
52
|
+
? 'delete'
|
|
53
|
+
: strategies.has('anonymize') ? 'anonymize' : 'retain';
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
한 엔티티의 fields에 `delete`와 `retain`이 섞여 있으면 `delete`가 승리하고, 그 결과 `executor.erase(...)`가 **전체 row 삭제**로 내려간다. 이렇게 되면 retain 대상 필드까지 함께 삭제된다.
|
|
57
|
+
|
|
58
|
+
### 왜 위험한가
|
|
59
|
+
|
|
60
|
+
- 법정보관 의무가 있는 데이터를 디폴트 경로에서 함께 삭제할 수 있다.
|
|
61
|
+
- spec 상 `retain`은 삭제 대상이 아니라 건너뛰고 근거를 기록해야 한다.
|
|
62
|
+
- 현재 동작은 "row delete보다 field delete가 우선"이어야 하는 도메인 의미와 반대로 움직인다.
|
|
63
|
+
|
|
64
|
+
### 관련 spec
|
|
65
|
+
|
|
66
|
+
- `docs/spec.md` §2.1 `delete`
|
|
67
|
+
- `docs/spec.md` §2.3 `retain`
|
|
68
|
+
- `docs/spec.md` §6 Erase 플로우
|
|
69
|
+
|
|
70
|
+
### 권장 결정
|
|
71
|
+
|
|
72
|
+
- `retain`이 하나라도 있으면 row delete를 금지한다.
|
|
73
|
+
- mixed strategy row는 `anonymize/update-fields` 경로로 강등한다.
|
|
74
|
+
- `delete` 대상 필드는 null 또는 빈값으로 업데이트하고, `retain` 대상 필드는 그대로 둔다.
|
|
75
|
+
- 컴파일 단계에서 `delete + retain` 조합을 허용하되, 실행 단계에서 row delete로 내려가지 않게 보장한다.
|
|
76
|
+
|
|
77
|
+
### 예상 수정 파일
|
|
78
|
+
|
|
79
|
+
- `src/erase-runner.ts`
|
|
80
|
+
- `src/policy-compiler.ts`
|
|
81
|
+
- `src/types.ts` 또는 정책/실행 계획 타입 정의 파일
|
|
82
|
+
|
|
83
|
+
### 완료 기준
|
|
84
|
+
|
|
85
|
+
- `retain` 필드가 하나라도 있는 엔티티는 row delete를 실행하지 않는다.
|
|
86
|
+
- mixed strategy row에서 `delete` 필드만 제거되고 `retain` 필드는 남는다.
|
|
87
|
+
- `stats.retained` 또는 동등한 결과 구조에 retain 근거가 남는다.
|
|
88
|
+
|
|
89
|
+
### 필요한 테스트
|
|
90
|
+
|
|
91
|
+
- `delete + retain` 혼합 정책에서 retain 필드 보존 검증
|
|
92
|
+
- `delete-only` 정책에서만 row delete 또는 field delete가 동작하는지 검증
|
|
93
|
+
- `anonymize + retain` 혼합 정책에서 retain 필드 비변경 검증
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
### 2. `prisma/from-prisma.ts:48-52` — `delete-fields` 분기가 사실상 no-op
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const result = await delegate.updateMany({
|
|
101
|
+
where: whereFor(subjectId, tenantId),
|
|
102
|
+
data: {},
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`rowLevel === 'delete-fields'`일 때 어떤 필드를 null 또는 빈값으로 만들어야 하는지 정보가 어댑터로 전달되지 않는다. 현재 `EntityExecutor.erase(subjectId, tenantId, rowLevel)` 시그니처만으로는 구현자가 지울 필드를 알 수 없다. Prisma는 빈 `data`를 받은 `updateMany`에서 실제 데이터를 바꾸지 않는다.
|
|
107
|
+
|
|
108
|
+
### 왜 위험한가
|
|
109
|
+
|
|
110
|
+
- spec에서 기본값으로 선언된 `delete-fields`가 실제로는 동작하지 않는다.
|
|
111
|
+
- FK 안정성을 위해 row delete를 피하려는 엔티티에서 삭제가 수행되지 않을 수 있다.
|
|
112
|
+
- "삭제가 수행되었다"고 집계되더라도 실제 데이터는 남아 있을 수 있다.
|
|
113
|
+
|
|
114
|
+
### 관련 spec
|
|
115
|
+
|
|
116
|
+
- `docs/spec.md` §2.1 `delete`
|
|
117
|
+
- `docs/spec.md` §6 Erase 플로우
|
|
118
|
+
- `docs/spec.md` §7 공개 API 표면
|
|
119
|
+
|
|
120
|
+
### 권장 결정
|
|
121
|
+
|
|
122
|
+
공개 의미론은 유지하되, 내부 어댑터 계약을 확장한다.
|
|
123
|
+
|
|
124
|
+
- `delete-fields`를 유지한다. 이 의미는 spec에 이미 명시되어 있으므로 v0.1에서 폐기하지 않는다.
|
|
125
|
+
- 대신 실행기는 어댑터에 `rowLevel`만 넘기지 말고, 실제 업데이트 계획을 함께 넘긴다.
|
|
126
|
+
- 예시:
|
|
127
|
+
- `erase(subjectId, tenantId, { rowLevel, deleteFields, replacements })`
|
|
128
|
+
- 또는 `applyPlan(subjectId, tenantId, plan)`
|
|
129
|
+
- Prisma 어댑터는 이 계획을 `updateMany({ data })`로 그대로 번역한다.
|
|
130
|
+
- 내부 구현에서는 `delete-fields`와 `anonymize`를 같은 update 경로로 처리하되, 도메인 의미는 구분해 유지한다.
|
|
131
|
+
|
|
132
|
+
### 비권장 대안
|
|
133
|
+
|
|
134
|
+
- `delete-fields` 자체를 제거하고 spec를 축소하는 선택은 가능하지만, 현재 spec와 어긋나므로 v0.1에서는 권장하지 않는다.
|
|
135
|
+
|
|
136
|
+
### 예상 수정 파일
|
|
137
|
+
|
|
138
|
+
- `src/prisma/from-prisma.ts`
|
|
139
|
+
- `src/ports/entity-executor.ts` 또는 동등한 인터페이스 파일
|
|
140
|
+
- `src/erase-runner.ts`
|
|
141
|
+
- 관련 테스트 파일
|
|
142
|
+
|
|
143
|
+
### 완료 기준
|
|
144
|
+
|
|
145
|
+
- `delete-fields` 경로에서 실제 변경 대상 필드가 `updateMany.data`에 채워진다.
|
|
146
|
+
- nullable / non-nullable 필드에 대한 삭제 대체값 정책이 일관된다.
|
|
147
|
+
- 집계 수치와 실제 DB 상태가 일치한다.
|
|
148
|
+
|
|
149
|
+
### 필요한 테스트
|
|
150
|
+
|
|
151
|
+
- `delete-fields` 요청 시 지정 필드만 null 또는 빈값 처리되는지 검증
|
|
152
|
+
- Prisma 어댑터가 빈 `data`를 만들지 않는지 검증
|
|
153
|
+
- row delete가 금지된 엔티티에서 field delete가 정상 동작하는지 통합 검증
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## 이번 라운드 포함 권장 이슈
|
|
158
|
+
|
|
159
|
+
### 3. `export-runner.ts:35` — export stats에 `strategy: 'delete'`가 박혀 있음
|
|
160
|
+
|
|
161
|
+
Export는 삭제가 아니라 데이터 전달이다. export 통계에 `strategy: 'delete'`가 들어가면 감사 로그와 운영 지표 해석이 틀어진다.
|
|
162
|
+
|
|
163
|
+
권장 조치:
|
|
164
|
+
|
|
165
|
+
- export 통계에서 `strategy` 필드를 제거하거나 `'export'` 같은 별도 라벨로 바꾼다.
|
|
166
|
+
- `docs/spec.md` §5 Export 플로우, §9 Outbox 이벤트와 맞는 용어를 사용한다.
|
|
167
|
+
|
|
168
|
+
완료 기준:
|
|
169
|
+
|
|
170
|
+
- export 통계가 erase 의미를 재사용하지 않는다.
|
|
171
|
+
- 운영자 입장에서 export/erase 로그가 혼동되지 않는다.
|
|
172
|
+
|
|
173
|
+
### 4. `data-subject.service.ts` — export/erase outbox 이벤트 비대칭
|
|
174
|
+
|
|
175
|
+
현재 `erase()`는 `erasure_requested`, `request_completed`, `request_failed`를 발행하지만, `export()`는 생성 시점 `request_created`만 발행한다. 이 상태에서는 다운스트림이 export 완료/실패를 감지할 수 없다.
|
|
176
|
+
|
|
177
|
+
권장 조치:
|
|
178
|
+
|
|
179
|
+
- export도 완료 시 `request_completed`, 실패 시 `request_failed`를 발행한다.
|
|
180
|
+
- 이벤트 의미는 `request.type`으로 구분하고, 성공/실패 이벤트 이름은 공통으로 유지한다.
|
|
181
|
+
|
|
182
|
+
관련 spec:
|
|
183
|
+
|
|
184
|
+
- `docs/spec.md` §4 라이프사이클
|
|
185
|
+
- `docs/spec.md` §5 Export 플로우
|
|
186
|
+
- `docs/spec.md` §9 Outbox 이벤트 스펙
|
|
187
|
+
|
|
188
|
+
완료 기준:
|
|
189
|
+
|
|
190
|
+
- export와 erase 모두 생성/완료/실패의 생애주기 이벤트가 대칭적으로 발행된다.
|
|
191
|
+
- 다운스트림이 type 기반으로 후처리할 수 있다.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## 후속 라운드 권장 이슈
|
|
196
|
+
|
|
197
|
+
### 5. `erase-runner.ts:64-71` — 검증 실패 시 DB 롤백 없음
|
|
198
|
+
|
|
199
|
+
현재는 삭제/익명화 DML 이후 verification scan이 실패해도 이미 변경된 데이터는 되돌릴 수 없다.
|
|
200
|
+
|
|
201
|
+
권장 조치:
|
|
202
|
+
|
|
203
|
+
- 최소한 `README` 또는 `LIMITATIONS`에 현재 보장 범위를 명시한다.
|
|
204
|
+
- 가능하면 runner에 Unit-of-Work 또는 transaction hook을 주입해 롤백 가능 구조로 확장한다.
|
|
205
|
+
|
|
206
|
+
### 6. `data-subject.service.ts:108, 178, 185` — 에러 핸들링이 느슨
|
|
207
|
+
|
|
208
|
+
현재는 비-`Error` 예외, 라이브러리 내부 `new Error(...)`, `failureReason` 누락 가능성이 섞여 있어 호출자가 안정적으로 분기하기 어렵다.
|
|
209
|
+
|
|
210
|
+
권장 조치:
|
|
211
|
+
|
|
212
|
+
- 라이브러리 내부 예외를 `DataSubjectError` 계열로 통일한다.
|
|
213
|
+
- `unknown` 예외를 문자열화하는 공통 유틸을 둔다.
|
|
214
|
+
- `errors.ts`와 API 문서의 기대 상태 코드를 함께 맞춘다.
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Spec 정합성 매핑
|
|
219
|
+
|
|
220
|
+
| 이슈 | 현재 문제 | 깨지는 spec | 필요한 결과 |
|
|
221
|
+
|---|---|---|---|
|
|
222
|
+
| #1 mixed `delete + retain` | retain 포함 row가 통째로 삭제될 수 있음 | §2.1, §2.3, §6 | retain 우선, row delete 금지 |
|
|
223
|
+
| #2 `delete-fields` no-op | 필드 삭제 정보가 어댑터에 전달되지 않음 | §2.1, §6, §7 | 필드 업데이트 계획 전달 |
|
|
224
|
+
| #3 export stats label | export 결과가 erase처럼 기록됨 | §5, §9 | export 의미에 맞는 통계 라벨 |
|
|
225
|
+
| #4 outbox 비대칭 | export 완료/실패를 감지할 수 없음 | §4, §5, §9 | 공통 생애주기 이벤트 정렬 |
|
|
226
|
+
| #5 rollback 부재 | 실패 후 부분 적용 가능 | §4, §6 | 보장 범위 문서화 또는 트랜잭션 훅 |
|
|
227
|
+
| #6 에러 타입 불일치 | 호출자 분기 어려움 | §10 | 에러 코드 체계 통일 |
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## 구현 순서 제안
|
|
232
|
+
|
|
233
|
+
1. `erase-runner`에서 mixed strategy 분류 로직을 재설계한다.
|
|
234
|
+
2. 어댑터 계약을 "rowLevel" 중심에서 "실행 계획" 중심으로 확장한다.
|
|
235
|
+
3. Prisma 어댑터가 field delete를 실제 `updateMany.data`로 번역하도록 바꾼다.
|
|
236
|
+
4. export 통계 라벨과 outbox 이벤트를 spec에 맞게 정렬한다.
|
|
237
|
+
5. 테스트를 보강해 mixed strategy, `delete-fields`, export 완료/실패 이벤트를 먼저 잠근다.
|
|
238
|
+
6. 후속 라운드에서 롤백 보장과 에러 타입 통일을 진행한다.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## 테스트 계획
|
|
243
|
+
|
|
244
|
+
### 단위 테스트
|
|
245
|
+
|
|
246
|
+
- mixed strategy 분류 함수: `delete + retain`이면 row delete 금지
|
|
247
|
+
- 정책 컴파일 결과: `rowLevel` 기본값과 필드 업데이트 계획 검증
|
|
248
|
+
- 에러 변환 유틸: `unknown` 예외 문자열화 검증
|
|
249
|
+
|
|
250
|
+
### 통합 테스트
|
|
251
|
+
|
|
252
|
+
- InMemory Prisma-like 환경에서 `delete-fields`가 실제 필드 값을 변경하는지 검증
|
|
253
|
+
- mixed strategy 엔티티에서 retain 필드 보존 + delete 필드 제거 검증
|
|
254
|
+
- export 완료/실패 시 request state와 outbox 이벤트가 함께 맞는지 검증
|
|
255
|
+
|
|
256
|
+
### 계약 테스트
|
|
257
|
+
|
|
258
|
+
- `EntityExecutor` 구현이 field update plan을 누락 없이 적용하는지 검증
|
|
259
|
+
- Prisma adapter가 빈 `data`를 허용하지 않도록 검증
|
|
260
|
+
|
|
261
|
+
### 회귀 테스트
|
|
262
|
+
|
|
263
|
+
- `delete-only` 엔티티의 기존 erase 동작 유지
|
|
264
|
+
- `anonymize-only` 엔티티의 대체값 적용 유지
|
|
265
|
+
- retain 엔티티의 `stats.retained` 기록 유지
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## 비목표 및 메모
|
|
270
|
+
|
|
271
|
+
이번 라운드의 비목표:
|
|
272
|
+
|
|
273
|
+
- ZIP 스트리밍 최적화
|
|
274
|
+
- DI 구조 전면 개편
|
|
275
|
+
- 상태 전이 가드 전면 도입
|
|
276
|
+
- `retain.until` 포맷 파서 추가
|
|
277
|
+
|
|
278
|
+
다만 위 항목들은 모두 남겨둘 가치가 있는 후속 과제다. 특히 상태 전이 가드, export manifest, public API 과노출 문제는 v0.2 전에 한 번 더 검토하는 것이 좋다.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Nits
|
|
283
|
+
|
|
284
|
+
- `legal-basis.ts:20` — `value.split(':', 2)` 블록은 regex 검증과 중복되므로 단순화 가능
|
|
285
|
+
- `policy-compiler.ts:60` — `(entry as { legalBasis?: string })` 캐스팅 제거 가능
|
|
286
|
+
- `policy-compiler.ts` — `retain.until` 포맷 파서/밸리데이션 부재
|
|
287
|
+
- `erase-runner.ts:26, 39` — fallback 및 `select` 호출 중복
|
|
288
|
+
- `data-subject.service.ts` — 요청마다 runner를 직접 생성, 테스트 주입성 낮음
|
|
289
|
+
- `data-subject.service.ts:170` — 상태 전이 가드 없음
|
|
290
|
+
- `export-runner.ts` — ZIP 전체를 `nodebuffer`로 메모리에 적재
|
|
291
|
+
- export artifact manifest 없음
|
|
292
|
+
- `errors.ts` — `InvalidPolicy`의 런타임/프로그래머 에러 경계 문서화 필요
|
|
293
|
+
- `index.ts` — `Registry` public 노출 범위 재검토 필요
|