@crewhaus/data-retention-engine 0.1.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/package.json +41 -0
- package/src/index.test.ts +280 -0
- package/src/index.ts +304 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/data-retention-engine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "GDPR-shaped data retention: retain / export / purge with cron-style sweeper + audit window override (Section 39)",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/errors": "0.0.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Max Meier",
|
|
20
|
+
"email": "max@studiomax.io",
|
|
21
|
+
"url": "https://studiomax.io"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
26
|
+
"directory": "packages/data-retention-engine"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/data-retention-engine#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "restricted"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"NOTICE"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
DataRetentionError,
|
|
4
|
+
InMemoryRecordStore,
|
|
5
|
+
type RetentionRecord,
|
|
6
|
+
_msPerDayForTest,
|
|
7
|
+
createDataRetentionEngine,
|
|
8
|
+
} from "./index";
|
|
9
|
+
|
|
10
|
+
const DAY_MS = _msPerDayForTest;
|
|
11
|
+
|
|
12
|
+
function record(
|
|
13
|
+
id: string,
|
|
14
|
+
tenantId: string,
|
|
15
|
+
kind: string,
|
|
16
|
+
ageDays: number,
|
|
17
|
+
payload: unknown = {},
|
|
18
|
+
fromNow = Date.now(),
|
|
19
|
+
): RetentionRecord {
|
|
20
|
+
return {
|
|
21
|
+
id,
|
|
22
|
+
tenantId,
|
|
23
|
+
kind,
|
|
24
|
+
createdAt: fromNow - ageDays * DAY_MS,
|
|
25
|
+
payload,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("retention policies (T1)", () => {
|
|
30
|
+
test("retain stores tenant+kind+duration", () => {
|
|
31
|
+
const store = new InMemoryRecordStore();
|
|
32
|
+
const eng = createDataRetentionEngine({ recordStore: store });
|
|
33
|
+
eng.retain("tenant-a", "audit", 30);
|
|
34
|
+
eng.retain("tenant-b", "metrics", 7);
|
|
35
|
+
expect(eng.listRetention()).toEqual([
|
|
36
|
+
{ tenantId: "tenant-a", kind: "audit", durationDays: 30 },
|
|
37
|
+
{ tenantId: "tenant-b", kind: "metrics", durationDays: 7 },
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("retain composition takes the LONGER duration when re-set", () => {
|
|
42
|
+
const eng = createDataRetentionEngine({ recordStore: new InMemoryRecordStore() });
|
|
43
|
+
eng.retain("tenant-a", "audit", 30);
|
|
44
|
+
eng.retain("tenant-a", "audit", 7);
|
|
45
|
+
eng.retain("tenant-a", "audit", 60);
|
|
46
|
+
const policies = eng.listRetention();
|
|
47
|
+
expect(policies).toHaveLength(1);
|
|
48
|
+
expect(policies[0]?.durationDays).toBe(60);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("removeRetention drops the policy", () => {
|
|
52
|
+
const eng = createDataRetentionEngine({ recordStore: new InMemoryRecordStore() });
|
|
53
|
+
eng.retain("tenant-a", "audit", 30);
|
|
54
|
+
eng.removeRetention("tenant-a", "audit");
|
|
55
|
+
expect(eng.listRetention()).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("invalid retain inputs throw", () => {
|
|
59
|
+
const eng = createDataRetentionEngine({ recordStore: new InMemoryRecordStore() });
|
|
60
|
+
expect(() => eng.retain("", "k", 1)).toThrow(DataRetentionError);
|
|
61
|
+
expect(() => eng.retain("t", "", 1)).toThrow(DataRetentionError);
|
|
62
|
+
expect(() => eng.retain("t", "k", 0)).toThrow(DataRetentionError);
|
|
63
|
+
expect(() => eng.retain("t", "k", -1)).toThrow(DataRetentionError);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("purge (T1 + T8 cross-tenant isolation)", () => {
|
|
68
|
+
test("purge respects retention policy — recent records survive", async () => {
|
|
69
|
+
const now = 1_700_000_000_000;
|
|
70
|
+
const store = new InMemoryRecordStore([
|
|
71
|
+
record("a-recent", "tenant-a", "audit", 5, {}, now),
|
|
72
|
+
record("a-old", "tenant-a", "audit", 100, {}, now),
|
|
73
|
+
]);
|
|
74
|
+
const eng = createDataRetentionEngine({ recordStore: store, now: () => now });
|
|
75
|
+
eng.retain("tenant-a", "audit", 30);
|
|
76
|
+
const result = await eng.purge("tenant-a");
|
|
77
|
+
expect(result.deleted).toBe(1);
|
|
78
|
+
expect(result.retentionDeferred).toEqual(["a-recent"]);
|
|
79
|
+
expect(store.ids()).toEqual(["a-recent"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("purge with restrictKind only deletes that kind", async () => {
|
|
83
|
+
const now = 1_700_000_000_000;
|
|
84
|
+
const store = new InMemoryRecordStore([
|
|
85
|
+
record("audit-1", "tenant-a", "audit", 100, {}, now),
|
|
86
|
+
record("metrics-1", "tenant-a", "metrics", 100, {}, now),
|
|
87
|
+
]);
|
|
88
|
+
const eng = createDataRetentionEngine({ recordStore: store, now: () => now });
|
|
89
|
+
await eng.purge("tenant-a", { kind: "audit" });
|
|
90
|
+
expect(store.ids()).toEqual(["metrics-1"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("purge with `before` cutoff only deletes pre-cutoff records", async () => {
|
|
94
|
+
const now = 1_700_000_000_000;
|
|
95
|
+
const cutoff = now - 50 * DAY_MS;
|
|
96
|
+
const store = new InMemoryRecordStore([
|
|
97
|
+
record("old", "tenant-a", "audit", 100, {}, now), // pre-cutoff
|
|
98
|
+
record("recent", "tenant-a", "audit", 30, {}, now), // post-cutoff
|
|
99
|
+
]);
|
|
100
|
+
const eng = createDataRetentionEngine({ recordStore: store, now: () => now });
|
|
101
|
+
await eng.purge("tenant-a", { before: cutoff });
|
|
102
|
+
expect(store.ids()).toEqual(["recent"]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("T8 — tenant-A purge does NOT touch tenant-B records", async () => {
|
|
106
|
+
const now = 1_700_000_000_000;
|
|
107
|
+
const store = new InMemoryRecordStore([
|
|
108
|
+
record("a-1", "tenant-a", "audit", 100, {}, now),
|
|
109
|
+
record("a-2", "tenant-a", "audit", 200, {}, now),
|
|
110
|
+
record("b-1", "tenant-b", "audit", 100, {}, now),
|
|
111
|
+
record("b-2", "tenant-b", "audit", 200, {}, now),
|
|
112
|
+
]);
|
|
113
|
+
const eng = createDataRetentionEngine({ recordStore: store, now: () => now });
|
|
114
|
+
await eng.purge("tenant-a");
|
|
115
|
+
// tenant-a records gone; tenant-b records intact.
|
|
116
|
+
expect([...store.ids()].sort()).toEqual(["b-1", "b-2"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("missing tenantId throws", async () => {
|
|
120
|
+
const eng = createDataRetentionEngine({ recordStore: new InMemoryRecordStore() });
|
|
121
|
+
await expect(eng.purge("")).rejects.toThrow(DataRetentionError);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("audit-window override", () => {
|
|
126
|
+
test("active audit window blocks purge", async () => {
|
|
127
|
+
const now = 1_700_000_000_000;
|
|
128
|
+
const store = new InMemoryRecordStore([record("a-1", "tenant-a", "audit", 200, {}, now)]);
|
|
129
|
+
const eng = createDataRetentionEngine({ recordStore: store, now: () => now });
|
|
130
|
+
eng.addAuditWindow({
|
|
131
|
+
frameworkId: "soc2",
|
|
132
|
+
controlId: "CC6.1",
|
|
133
|
+
expiresAt: now + 10 * DAY_MS,
|
|
134
|
+
});
|
|
135
|
+
const result = await eng.purge("tenant-a");
|
|
136
|
+
expect(result.deleted).toBe(0);
|
|
137
|
+
expect(result.deferred).toEqual(["a-1"]);
|
|
138
|
+
expect(result.auditWindowDeferred).toHaveLength(1);
|
|
139
|
+
expect(result.auditWindowDeferred[0]?.frameworkId).toBe("soc2");
|
|
140
|
+
expect(store.ids()).toEqual(["a-1"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("expired audit window does not block purge", async () => {
|
|
144
|
+
const now = 1_700_000_000_000;
|
|
145
|
+
const store = new InMemoryRecordStore([record("a-1", "tenant-a", "audit", 200, {}, now)]);
|
|
146
|
+
const eng = createDataRetentionEngine({ recordStore: store, now: () => now });
|
|
147
|
+
expect(() =>
|
|
148
|
+
eng.addAuditWindow({
|
|
149
|
+
frameworkId: "soc2",
|
|
150
|
+
controlId: "CC6.1",
|
|
151
|
+
expiresAt: now - 1, // already expired
|
|
152
|
+
}),
|
|
153
|
+
).toThrow(DataRetentionError);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("listAuditWindows drops expired entries", () => {
|
|
157
|
+
let mutableNow = 1_700_000_000_000;
|
|
158
|
+
const eng = createDataRetentionEngine({
|
|
159
|
+
recordStore: new InMemoryRecordStore(),
|
|
160
|
+
now: () => mutableNow,
|
|
161
|
+
});
|
|
162
|
+
eng.addAuditWindow({
|
|
163
|
+
frameworkId: "soc2",
|
|
164
|
+
controlId: "CC6.1",
|
|
165
|
+
expiresAt: mutableNow + 5 * DAY_MS,
|
|
166
|
+
});
|
|
167
|
+
expect(eng.listAuditWindows()).toHaveLength(1);
|
|
168
|
+
mutableNow += 10 * DAY_MS;
|
|
169
|
+
expect(eng.listAuditWindows()).toHaveLength(0);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("export (right-to-export)", () => {
|
|
174
|
+
test("JSON format returns pretty-printed array", async () => {
|
|
175
|
+
const store = new InMemoryRecordStore([
|
|
176
|
+
record("a-1", "tenant-a", "audit", 5),
|
|
177
|
+
record("a-2", "tenant-a", "audit", 10),
|
|
178
|
+
record("b-1", "tenant-b", "audit", 5),
|
|
179
|
+
]);
|
|
180
|
+
const eng = createDataRetentionEngine({ recordStore: store });
|
|
181
|
+
const out = await eng.export("tenant-a", { format: "json" });
|
|
182
|
+
const parsed = JSON.parse(out);
|
|
183
|
+
expect(parsed).toHaveLength(2);
|
|
184
|
+
expect(parsed[0].id).toBe("a-1");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("NDJSON format returns one record per line", async () => {
|
|
188
|
+
const store = new InMemoryRecordStore([
|
|
189
|
+
record("a-1", "tenant-a", "audit", 5),
|
|
190
|
+
record("a-2", "tenant-a", "metrics", 5),
|
|
191
|
+
]);
|
|
192
|
+
const eng = createDataRetentionEngine({ recordStore: store });
|
|
193
|
+
const out = await eng.export("tenant-a", { format: "ndjson" });
|
|
194
|
+
const lines = out.split("\n");
|
|
195
|
+
expect(lines).toHaveLength(2);
|
|
196
|
+
const first = lines[0];
|
|
197
|
+
expect(first).toBeDefined();
|
|
198
|
+
if (first === undefined) throw new Error("missing line");
|
|
199
|
+
expect(JSON.parse(first).id).toBe("a-1");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("kinds filter restricts exported records", async () => {
|
|
203
|
+
const store = new InMemoryRecordStore([
|
|
204
|
+
record("a-1", "tenant-a", "audit", 5),
|
|
205
|
+
record("a-2", "tenant-a", "metrics", 5),
|
|
206
|
+
]);
|
|
207
|
+
const eng = createDataRetentionEngine({ recordStore: store });
|
|
208
|
+
const out = await eng.export("tenant-a", { format: "ndjson", kinds: ["audit"] });
|
|
209
|
+
expect(out.split("\n")).toHaveLength(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("export refuses cross-tenant fishing", async () => {
|
|
213
|
+
const store = new InMemoryRecordStore([
|
|
214
|
+
record("a-1", "tenant-a", "audit", 5),
|
|
215
|
+
record("b-1", "tenant-b", "audit", 5),
|
|
216
|
+
]);
|
|
217
|
+
const eng = createDataRetentionEngine({ recordStore: store });
|
|
218
|
+
const out = await eng.export("tenant-a", { format: "json" });
|
|
219
|
+
expect(out.includes("tenant-b")).toBe(false);
|
|
220
|
+
expect(out.includes("b-1")).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("sweep (cron-style)", () => {
|
|
225
|
+
test("deletes expired records across all tenants", async () => {
|
|
226
|
+
const now = 1_700_000_000_000;
|
|
227
|
+
const store = new InMemoryRecordStore([
|
|
228
|
+
record("a-old", "tenant-a", "audit", 100, {}, now),
|
|
229
|
+
record("a-new", "tenant-a", "audit", 5, {}, now),
|
|
230
|
+
record("b-old", "tenant-b", "audit", 200, {}, now),
|
|
231
|
+
]);
|
|
232
|
+
const eng = createDataRetentionEngine({
|
|
233
|
+
recordStore: store,
|
|
234
|
+
now: () => now,
|
|
235
|
+
defaultRetentionDays: 30,
|
|
236
|
+
});
|
|
237
|
+
const result = await eng.sweep();
|
|
238
|
+
expect(result.deletedCount).toBe(2);
|
|
239
|
+
expect(result.recordsKept).toBe(1);
|
|
240
|
+
expect(store.ids()).toEqual(["a-new"]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("idempotent — re-running yields zero deletes (T9)", async () => {
|
|
244
|
+
const now = 1_700_000_000_000;
|
|
245
|
+
const store = new InMemoryRecordStore([
|
|
246
|
+
record("a-old", "tenant-a", "audit", 100, {}, now),
|
|
247
|
+
record("a-new", "tenant-a", "audit", 5, {}, now),
|
|
248
|
+
]);
|
|
249
|
+
const eng = createDataRetentionEngine({
|
|
250
|
+
recordStore: store,
|
|
251
|
+
now: () => now,
|
|
252
|
+
defaultRetentionDays: 30,
|
|
253
|
+
});
|
|
254
|
+
const r1 = await eng.sweep();
|
|
255
|
+
const r2 = await eng.sweep();
|
|
256
|
+
expect(r1.deletedCount).toBe(1);
|
|
257
|
+
expect(r2.deletedCount).toBe(0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("audit window blocks the entire sweep", async () => {
|
|
261
|
+
const now = 1_700_000_000_000;
|
|
262
|
+
const store = new InMemoryRecordStore([
|
|
263
|
+
record("a-old", "tenant-a", "audit", 100, {}, now),
|
|
264
|
+
record("b-old", "tenant-b", "audit", 200, {}, now),
|
|
265
|
+
]);
|
|
266
|
+
const eng = createDataRetentionEngine({
|
|
267
|
+
recordStore: store,
|
|
268
|
+
now: () => now,
|
|
269
|
+
defaultRetentionDays: 30,
|
|
270
|
+
});
|
|
271
|
+
eng.addAuditWindow({
|
|
272
|
+
frameworkId: "iso27001",
|
|
273
|
+
controlId: "A.12.4",
|
|
274
|
+
expiresAt: now + 10 * DAY_MS,
|
|
275
|
+
});
|
|
276
|
+
const result = await eng.sweep();
|
|
277
|
+
expect(result.deletedCount).toBe(0);
|
|
278
|
+
expect(result.recordsKept).toBe(2);
|
|
279
|
+
});
|
|
280
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Catalog R17 `data-retention-engine` — Section 39 GDPR-shaped retention.
|
|
5
|
+
*
|
|
6
|
+
* Three operations:
|
|
7
|
+
* retain(tenantId, kind, durationDays) — pin records for at least
|
|
8
|
+
* this long. The cron-style sweeper consults the policy table
|
|
9
|
+
* before deleting. Multiple retain() calls compose: the longest
|
|
10
|
+
* duration wins.
|
|
11
|
+
* export(tenantId, format) — right-to-export. Streams
|
|
12
|
+
* a tenant's records out in JSON or NDJSON.
|
|
13
|
+
* purge(tenantId, opts?) — right-to-delete. Honors a
|
|
14
|
+
* compliance-controls "do not purge during audit window" override
|
|
15
|
+
* so SOC 2 evidence collection isn't disrupted.
|
|
16
|
+
*
|
|
17
|
+
* The engine is record-store-agnostic. Callers supply a `RecordStore`
|
|
18
|
+
* shape (list / get / delete / count) so the engine can operate over
|
|
19
|
+
* the audit-log JSONL files, a SQL table, an object-store prefix, etc.
|
|
20
|
+
*
|
|
21
|
+
* Audit-window override: callers register an `AuditWindow` (with
|
|
22
|
+
* `frameworkId` / `controlId` / `expiresAt`) via `addAuditWindow`. As
|
|
23
|
+
* long as any window is active, `purge()` skips records and returns
|
|
24
|
+
* the deferral reason. Cleared automatically once `expiresAt` passes.
|
|
25
|
+
*
|
|
26
|
+
* Cross-tenant isolation: every operation requires an explicit
|
|
27
|
+
* `tenantId`; the engine refuses to operate on records that don't
|
|
28
|
+
* report a matching `tenantId`. T8 isolation test asserts that a
|
|
29
|
+
* tenant-A purge does not touch tenant-B records.
|
|
30
|
+
*
|
|
31
|
+
* Layer R17. Pairs with `audit-log` (R-infra — primary record store
|
|
32
|
+
* for audit data), `tenancy` (R-infra — tenant-scoping primitives).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export class DataRetentionError extends CrewhausError {
|
|
36
|
+
override readonly name = "DataRetentionError";
|
|
37
|
+
constructor(message: string, cause?: unknown) {
|
|
38
|
+
super("config", message, cause);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type RetentionRecord = {
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly tenantId: string;
|
|
45
|
+
readonly kind: string;
|
|
46
|
+
readonly createdAt: number;
|
|
47
|
+
readonly payload: unknown;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export interface RecordStore {
|
|
51
|
+
/** Iterate every record (retention engine filters per-tenant downstream). */
|
|
52
|
+
listAll(): AsyncIterable<RetentionRecord>;
|
|
53
|
+
/** Iterate records for a specific tenant. */
|
|
54
|
+
listByTenant(tenantId: string): AsyncIterable<RetentionRecord>;
|
|
55
|
+
/** Delete a record by id. Returns true if a record was actually deleted. */
|
|
56
|
+
delete(id: string): Promise<boolean>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type RetentionPolicy = {
|
|
60
|
+
readonly tenantId: string;
|
|
61
|
+
readonly kind: string;
|
|
62
|
+
readonly durationDays: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type AuditWindow = {
|
|
66
|
+
readonly frameworkId: string;
|
|
67
|
+
readonly controlId: string;
|
|
68
|
+
readonly expiresAt: number;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type ExportFormat = "json" | "ndjson";
|
|
72
|
+
|
|
73
|
+
export type ExportOptions = {
|
|
74
|
+
readonly format: ExportFormat;
|
|
75
|
+
readonly kinds?: ReadonlyArray<string>;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type PurgeOptions = {
|
|
79
|
+
readonly kind?: string;
|
|
80
|
+
readonly before?: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type PurgeResult = {
|
|
84
|
+
readonly tenantId: string;
|
|
85
|
+
readonly deleted: number;
|
|
86
|
+
readonly deferred: ReadonlyArray<string>;
|
|
87
|
+
readonly retentionDeferred: ReadonlyArray<string>;
|
|
88
|
+
readonly auditWindowDeferred: ReadonlyArray<{
|
|
89
|
+
readonly frameworkId: string;
|
|
90
|
+
readonly controlId: string;
|
|
91
|
+
readonly expiresAt: number;
|
|
92
|
+
}>;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type SweepResult = {
|
|
96
|
+
readonly deletedCount: number;
|
|
97
|
+
readonly recordsKept: number;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export interface DataRetentionEngine {
|
|
101
|
+
retain(tenantId: string, kind: string, durationDays: number): void;
|
|
102
|
+
removeRetention(tenantId: string, kind: string): void;
|
|
103
|
+
listRetention(): ReadonlyArray<RetentionPolicy>;
|
|
104
|
+
addAuditWindow(window: AuditWindow): void;
|
|
105
|
+
listAuditWindows(): ReadonlyArray<AuditWindow>;
|
|
106
|
+
/** Purge a single tenant's records. Honors retention + audit windows. */
|
|
107
|
+
purge(tenantId: string, opts?: PurgeOptions): Promise<PurgeResult>;
|
|
108
|
+
/** Stream a single tenant's records in the requested format. */
|
|
109
|
+
export(tenantId: string, opts: ExportOptions): Promise<string>;
|
|
110
|
+
/** Cron-style sweeper: walk all records and delete expired-by-policy ones. */
|
|
111
|
+
sweep(): Promise<SweepResult>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type CreateOptions = {
|
|
115
|
+
readonly recordStore: RecordStore;
|
|
116
|
+
readonly now?: () => number;
|
|
117
|
+
readonly defaultRetentionDays?: number;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
121
|
+
|
|
122
|
+
export function createDataRetentionEngine(opts: CreateOptions): DataRetentionEngine {
|
|
123
|
+
if (opts.recordStore === undefined) {
|
|
124
|
+
throw new DataRetentionError("recordStore is required");
|
|
125
|
+
}
|
|
126
|
+
const now = opts.now ?? ((): number => Date.now());
|
|
127
|
+
const defaultRetentionDays = opts.defaultRetentionDays ?? 90;
|
|
128
|
+
const policies = new Map<string, RetentionPolicy>();
|
|
129
|
+
const auditWindows: AuditWindow[] = [];
|
|
130
|
+
|
|
131
|
+
const policyKey = (tenantId: string, kind: string): string => `${tenantId}::${kind}`;
|
|
132
|
+
|
|
133
|
+
function activeAuditWindows(): ReadonlyArray<AuditWindow> {
|
|
134
|
+
const active = auditWindows.filter((w) => w.expiresAt > now());
|
|
135
|
+
// Mutate in place to drop expired windows so the array doesn't grow
|
|
136
|
+
// unboundedly across many sweeps.
|
|
137
|
+
auditWindows.length = 0;
|
|
138
|
+
auditWindows.push(...active);
|
|
139
|
+
return active;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function retentionEnd(record: RetentionRecord): number {
|
|
143
|
+
const key = policyKey(record.tenantId, record.kind);
|
|
144
|
+
const policy = policies.get(key);
|
|
145
|
+
const days = policy?.durationDays ?? defaultRetentionDays;
|
|
146
|
+
return record.createdAt + days * MS_PER_DAY;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
retain(tenantId, kind, durationDays): void {
|
|
151
|
+
if (typeof tenantId !== "string" || tenantId === "") {
|
|
152
|
+
throw new DataRetentionError("retain: tenantId required");
|
|
153
|
+
}
|
|
154
|
+
if (typeof kind !== "string" || kind === "") {
|
|
155
|
+
throw new DataRetentionError("retain: kind required");
|
|
156
|
+
}
|
|
157
|
+
if (!Number.isFinite(durationDays) || durationDays <= 0) {
|
|
158
|
+
throw new DataRetentionError("retain: durationDays must be > 0");
|
|
159
|
+
}
|
|
160
|
+
const key = policyKey(tenantId, kind);
|
|
161
|
+
const existing = policies.get(key);
|
|
162
|
+
if (existing === undefined || durationDays > existing.durationDays) {
|
|
163
|
+
policies.set(key, { tenantId, kind, durationDays });
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
removeRetention(tenantId, kind): void {
|
|
168
|
+
policies.delete(policyKey(tenantId, kind));
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
listRetention(): ReadonlyArray<RetentionPolicy> {
|
|
172
|
+
return [...policies.values()].sort(
|
|
173
|
+
(a, b) => a.tenantId.localeCompare(b.tenantId) || a.kind.localeCompare(b.kind),
|
|
174
|
+
);
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
addAuditWindow(window: AuditWindow): void {
|
|
178
|
+
if (window.expiresAt <= now()) {
|
|
179
|
+
throw new DataRetentionError(
|
|
180
|
+
`addAuditWindow: expiresAt (${window.expiresAt}) must be in the future (now=${now()})`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
auditWindows.push({ ...window });
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
listAuditWindows(): ReadonlyArray<AuditWindow> {
|
|
187
|
+
return activeAuditWindows().map((w) => ({ ...w }));
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async purge(tenantId, purgeOpts = {}): Promise<PurgeResult> {
|
|
191
|
+
if (typeof tenantId !== "string" || tenantId === "") {
|
|
192
|
+
throw new DataRetentionError("purge: tenantId required");
|
|
193
|
+
}
|
|
194
|
+
const active = activeAuditWindows();
|
|
195
|
+
const result = {
|
|
196
|
+
tenantId,
|
|
197
|
+
deleted: 0,
|
|
198
|
+
deferred: [] as string[],
|
|
199
|
+
retentionDeferred: [] as string[],
|
|
200
|
+
auditWindowDeferred: [] as PurgeResult["auditWindowDeferred"][number][],
|
|
201
|
+
};
|
|
202
|
+
for (const window of active) {
|
|
203
|
+
result.auditWindowDeferred.push({
|
|
204
|
+
frameworkId: window.frameworkId,
|
|
205
|
+
controlId: window.controlId,
|
|
206
|
+
expiresAt: window.expiresAt,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const cutoff = purgeOpts.before;
|
|
210
|
+
const restrictKind = purgeOpts.kind;
|
|
211
|
+
for await (const record of opts.recordStore.listByTenant(tenantId)) {
|
|
212
|
+
if (record.tenantId !== tenantId) continue; // T8 cross-tenant guard
|
|
213
|
+
if (restrictKind !== undefined && record.kind !== restrictKind) continue;
|
|
214
|
+
if (cutoff !== undefined && record.createdAt >= cutoff) continue;
|
|
215
|
+
if (active.length > 0) {
|
|
216
|
+
result.deferred.push(record.id);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (retentionEnd(record) > now()) {
|
|
220
|
+
result.retentionDeferred.push(record.id);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const deleted = await opts.recordStore.delete(record.id);
|
|
224
|
+
if (deleted) result.deleted += 1;
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
tenantId: result.tenantId,
|
|
228
|
+
deleted: result.deleted,
|
|
229
|
+
deferred: result.deferred,
|
|
230
|
+
retentionDeferred: result.retentionDeferred,
|
|
231
|
+
auditWindowDeferred: result.auditWindowDeferred,
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async export(tenantId, exportOpts): Promise<string> {
|
|
236
|
+
if (typeof tenantId !== "string" || tenantId === "") {
|
|
237
|
+
throw new DataRetentionError("export: tenantId required");
|
|
238
|
+
}
|
|
239
|
+
const records: RetentionRecord[] = [];
|
|
240
|
+
for await (const record of opts.recordStore.listByTenant(tenantId)) {
|
|
241
|
+
if (record.tenantId !== tenantId) continue; // T8 cross-tenant guard
|
|
242
|
+
if (exportOpts.kinds !== undefined && !exportOpts.kinds.includes(record.kind)) continue;
|
|
243
|
+
records.push(record);
|
|
244
|
+
}
|
|
245
|
+
if (exportOpts.format === "ndjson") {
|
|
246
|
+
return records.map((r) => JSON.stringify(r)).join("\n");
|
|
247
|
+
}
|
|
248
|
+
return JSON.stringify(records, null, 2);
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
async sweep(): Promise<SweepResult> {
|
|
252
|
+
const active = activeAuditWindows();
|
|
253
|
+
let deleted = 0;
|
|
254
|
+
let kept = 0;
|
|
255
|
+
for await (const record of opts.recordStore.listAll()) {
|
|
256
|
+
if (active.length > 0) {
|
|
257
|
+
kept += 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (retentionEnd(record) > now()) {
|
|
261
|
+
kept += 1;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const ok = await opts.recordStore.delete(record.id);
|
|
265
|
+
if (ok) deleted += 1;
|
|
266
|
+
else kept += 1;
|
|
267
|
+
}
|
|
268
|
+
return { deletedCount: deleted, recordsKept: kept };
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export class InMemoryRecordStore implements RecordStore {
|
|
274
|
+
private records: RetentionRecord[];
|
|
275
|
+
constructor(initial: ReadonlyArray<RetentionRecord> = []) {
|
|
276
|
+
this.records = [...initial];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async *listAll(): AsyncIterable<RetentionRecord> {
|
|
280
|
+
for (const r of [...this.records]) yield r;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async *listByTenant(tenantId: string): AsyncIterable<RetentionRecord> {
|
|
284
|
+
for (const r of [...this.records]) {
|
|
285
|
+
if (r.tenantId === tenantId) yield r;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async delete(id: string): Promise<boolean> {
|
|
290
|
+
const before = this.records.length;
|
|
291
|
+
this.records = this.records.filter((r) => r.id !== id);
|
|
292
|
+
return this.records.length < before;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
size(): number {
|
|
296
|
+
return this.records.length;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
ids(): ReadonlyArray<string> {
|
|
300
|
+
return this.records.map((r) => r.id);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export { MS_PER_DAY as _msPerDayForTest };
|