@ultimat3/jobs 30.8.0 → 31.0.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/CLAUDE.md +72 -107
- package/README.md +202 -55
- package/package.json +8 -7
- package/src/adapter.ts +37 -0
- package/src/backfill-ledger.ts +2 -2
- package/src/backfill-pass.ts +2 -1
- package/src/backfill-rate.ts +34 -5
- package/src/clock.ts +2 -2
- package/src/drain-wait.ts +1 -0
- package/src/driver-memory.ts +2 -2
- package/src/driver-pg-ddl.ts +19 -0
- package/src/driver-pg-event-wake-sql.ts +43 -22
- package/src/driver-pg-sql.ts +68 -27
- package/src/driver-pg.ts +11 -10
- package/src/driver-redis.ts +2 -2
- package/src/driver.ts +14 -8
- package/src/error-codes.ts +27 -0
- package/src/errors-concurrency.ts +1 -1
- package/src/errors.ts +1 -1
- package/src/events-cursor.ts +31 -0
- package/src/events-pg.ts +52 -6
- package/src/events.ts +72 -36
- package/src/export.ts +2 -2
- package/src/gdpr-audit.ts +75 -0
- package/src/gdpr-coverage.ts +94 -0
- package/src/gdpr-errors.ts +35 -0
- package/src/import.ts +2 -2
- package/src/index.ts +45 -206
- package/src/inspect-operator.ts +2 -2
- package/src/inspect.ts +1 -1
- package/src/job-instrument.ts +115 -0
- package/src/job.ts +41 -4
- package/src/jobs-retention.ts +23 -6
- package/src/leases.ts +13 -3
- package/src/ledger-book.ts +168 -0
- package/src/ledger-errors.ts +48 -0
- package/src/ledger.ts +145 -0
- package/src/limits.ts +57 -19
- package/src/lock-errors.ts +54 -0
- package/src/lock.ts +176 -0
- package/src/outbox-relay.ts +4 -0
- package/src/outbox.ts +3 -3
- package/src/partitions.ts +119 -0
- package/src/reindex-sweep.ts +94 -0
- package/src/reindex.ts +147 -0
- package/src/retry-classification.ts +3 -2
- package/src/retry-jitter.ts +32 -0
- package/src/retry.ts +13 -16
- package/src/scheduler.ts +10 -6
- package/src/steps-api.ts +44 -5
- package/src/steps-suspension.ts +34 -3
- package/src/steps-wait.ts +52 -4
- package/src/steps.ts +1 -0
- package/src/subject-jobs.ts +12 -4
- package/src/webhook.ts +2 -2
- package/src/worker-admit.ts +8 -5
- package/src/worker-drain-cutoff.ts +1 -0
- package/src/worker-loop.ts +28 -5
- package/src/worker-registry.ts +7 -5
- package/src/worker-run.ts +53 -9
- package/src/worker.ts +8 -8
package/src/events.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// 12:00:10, so a fire-and-forget emitter would silently strand every waiting run.
|
|
4
4
|
|
|
5
5
|
import type { Clock } from '@ultimat3/core';
|
|
6
|
-
import { finiteOption,
|
|
6
|
+
import { finiteOption, liveClock, logger, uuidV7 } from '@ultimat3/core';
|
|
7
|
+
import { adapterSlot } from '@ultimat3/core/adapter';
|
|
7
8
|
import type { DurationInput } from './clock';
|
|
8
9
|
import { finiteDurationMs, nowMs } from './clock';
|
|
10
|
+
import { eventBefore, pastCursor } from './events-cursor';
|
|
9
11
|
import type { PurgeTarget } from './purge';
|
|
10
12
|
import type { EventLookup } from './steps';
|
|
11
13
|
|
|
@@ -36,7 +38,8 @@ export interface EventBus extends EventLookup {
|
|
|
36
38
|
list(name?: string): Promise<readonly JobEvent[]>;
|
|
37
39
|
/**
|
|
38
40
|
* Delete every event past its expiry and answer how many went. Measured on the bus's own clock,
|
|
39
|
-
* like every other instant it holds. The memory bus also sweeps on
|
|
41
|
+
* like every other instant it holds. The memory bus also sweeps on a publish, at most once a
|
|
42
|
+
* second and only once something has expired; the stored one
|
|
40
43
|
* has no caller but the retention sweep (`eventsPurgeTarget`).
|
|
41
44
|
*/
|
|
42
45
|
purgeExpired(): Promise<number>;
|
|
@@ -49,8 +52,11 @@ export interface MemoryEventBusOptions {
|
|
|
49
52
|
readonly maxEvents?: number;
|
|
50
53
|
}
|
|
51
54
|
|
|
55
|
+
/** The least gap between two publish-time sweeps, on the bus's clock. */
|
|
56
|
+
const SWEEP_EVERY_MS = 1_000;
|
|
57
|
+
|
|
52
58
|
export function memoryEventBus(options: MemoryEventBusOptions = {}): EventBus {
|
|
53
|
-
const clock = options.clock ??
|
|
59
|
+
const clock = options.clock ?? liveClock;
|
|
54
60
|
// TWO screens, because these are two knobs: the default is declared at construction and belongs
|
|
55
61
|
// to whoever built the bus, `ttl` rides the publish CALL. One screen over `ttl ?? defaultTtl`
|
|
56
62
|
// told a caller who wrote `{ ttl: NaN }` to "pass a finite defaultTtl" — an instruction naming an
|
|
@@ -63,16 +69,31 @@ export function memoryEventBus(options: MemoryEventBusOptions = {}): EventBus {
|
|
|
63
69
|
);
|
|
64
70
|
const maxEvents = finiteOption('the memory event bus', 'maxEvents', options.maxEvents ?? 10_000);
|
|
65
71
|
const events = new Map<string, JobEvent>();
|
|
72
|
+
// Per name, so `find` reads one event's rows and never the whole bus (10k events made every
|
|
73
|
+
// find a 10k scan). Sets keep insertion order and delete in O(1), which the eviction needs.
|
|
74
|
+
const byName = new Map<string, Set<JobEvent>>();
|
|
75
|
+
/** No stored event expires before this, so a publish before it has nothing to sweep. */
|
|
76
|
+
let nextExpiry = Number.POSITIVE_INFINITY;
|
|
77
|
+
let lastSweptAt = Number.NEGATIVE_INFINITY;
|
|
78
|
+
|
|
79
|
+
const forget = (event: JobEvent): void => {
|
|
80
|
+
events.delete(event.id);
|
|
81
|
+
const named = byName.get(event.name);
|
|
82
|
+
named?.delete(event);
|
|
83
|
+
if (named?.size === 0) byName.delete(event.name);
|
|
84
|
+
};
|
|
66
85
|
|
|
67
86
|
const purgeExpired = (): number => {
|
|
68
87
|
const at = nowMs(clock);
|
|
69
88
|
let removed = 0;
|
|
70
|
-
|
|
89
|
+
nextExpiry = Number.POSITIVE_INFINITY;
|
|
90
|
+
for (const event of events.values()) {
|
|
71
91
|
if (event.expiresAt <= at) {
|
|
72
|
-
|
|
92
|
+
forget(event);
|
|
73
93
|
removed += 1;
|
|
74
|
-
}
|
|
94
|
+
} else if (event.expiresAt < nextExpiry) nextExpiry = event.expiresAt;
|
|
75
95
|
}
|
|
96
|
+
lastSweptAt = at;
|
|
76
97
|
return removed;
|
|
77
98
|
};
|
|
78
99
|
|
|
@@ -80,8 +101,11 @@ export function memoryEventBus(options: MemoryEventBusOptions = {}): EventBus {
|
|
|
80
101
|
stored: false,
|
|
81
102
|
now: () => Promise.resolve(nowMs(clock)),
|
|
82
103
|
publish(name, payload, publishOptions = {}) {
|
|
83
|
-
purgeExpired();
|
|
84
104
|
const at = nowMs(clock);
|
|
105
|
+
// A sweep is a pass over every event, so a publish pays for one only when something has
|
|
106
|
+
// expired AND the last one is a second old: a sweep per publish made a 10k fill 916 ms.
|
|
107
|
+
// `find` filters the expired either way; this only bounds the heap.
|
|
108
|
+
if (at >= nextExpiry && at - lastSweptAt >= SWEEP_EVERY_MS) purgeExpired();
|
|
85
109
|
const event: JobEvent = {
|
|
86
110
|
id: uuidV7(),
|
|
87
111
|
name,
|
|
@@ -97,10 +121,14 @@ export function memoryEventBus(options: MemoryEventBusOptions = {}): EventBus {
|
|
|
97
121
|
: { correlationKey: publishOptions.correlationKey }),
|
|
98
122
|
};
|
|
99
123
|
events.set(event.id, event);
|
|
124
|
+
const named = byName.get(name);
|
|
125
|
+
if (named === undefined) byName.set(name, new Set([event]));
|
|
126
|
+
else named.add(event);
|
|
127
|
+
if (event.expiresAt < nextExpiry) nextExpiry = event.expiresAt;
|
|
100
128
|
while (events.size > maxEvents) {
|
|
101
|
-
const oldest = events.
|
|
129
|
+
const oldest = events.values().next();
|
|
102
130
|
if (oldest.done === true) break;
|
|
103
|
-
|
|
131
|
+
forget(oldest.value);
|
|
104
132
|
}
|
|
105
133
|
logger.debug('jobs.event.published', {
|
|
106
134
|
event: name,
|
|
@@ -110,27 +138,23 @@ export function memoryEventBus(options: MemoryEventBusOptions = {}): EventBus {
|
|
|
110
138
|
},
|
|
111
139
|
|
|
112
140
|
/**
|
|
113
|
-
* Earliest matching event
|
|
114
|
-
*
|
|
115
|
-
*
|
|
141
|
+
* Earliest matching event past the cursor, in `(publishedAt, id)` order (`events-cursor.ts`),
|
|
142
|
+
* so a resumed step consumes events in publication order rather than jumping to the newest
|
|
143
|
+
* one — the rule `SQL_EVENT_FIND` states. The cursor is compared, never looked up: a refused
|
|
144
|
+
* event that has since been purged still excludes everything at or before it.
|
|
116
145
|
*/
|
|
117
|
-
find(name, correlationKey, afterMs, afterId) {
|
|
146
|
+
find(name, correlationKey, afterMs, afterId, _horizon, since) {
|
|
118
147
|
const at = nowMs(clock);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
148
|
+
// The wait's own line; no horizon here — a memory publish is visible as it is stamped.
|
|
149
|
+
const from = since?.at ?? afterMs;
|
|
150
|
+
const cursor = afterId === undefined ? { afterMs } : { afterMs, afterId };
|
|
122
151
|
let best: JobEvent | undefined;
|
|
123
|
-
for (const event of
|
|
124
|
-
if (event === cursor) {
|
|
125
|
-
passed = true;
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
if (!passed && event.publishedAt <= (cursor?.publishedAt ?? 0)) continue;
|
|
129
|
-
if (event.name !== name) continue;
|
|
152
|
+
for (const event of byName.get(name) ?? []) {
|
|
130
153
|
if (event.expiresAt <= at) continue;
|
|
131
|
-
if (event.publishedAt <
|
|
154
|
+
if (event.publishedAt < from || !pastCursor(event, cursor)) continue;
|
|
155
|
+
if (since?.refused?.includes(event.id) === true) continue;
|
|
132
156
|
if (correlationKey !== undefined && event.correlationKey !== correlationKey) continue;
|
|
133
|
-
if (best === undefined || event
|
|
157
|
+
if (best === undefined || eventBefore(event, best)) best = event;
|
|
134
158
|
}
|
|
135
159
|
return Promise.resolve(
|
|
136
160
|
best === undefined
|
|
@@ -163,21 +187,24 @@ export function eventsPurgeTarget(bus: EventBus): PurgeTarget {
|
|
|
163
187
|
return { name: EVENTS_PURGE_TARGET, purgeExpired: () => bus.purgeExpired() };
|
|
164
188
|
}
|
|
165
189
|
|
|
166
|
-
|
|
190
|
+
/** The process default under every installed bus — replaced only by `freshDefaultEventBus()`. */
|
|
191
|
+
let base: EventBus = memoryEventBus();
|
|
192
|
+
/** The installed bus: a stack over `base`, so boots and tests release in any order. */
|
|
193
|
+
const slot = adapterSlot<EventBus>(() => base);
|
|
167
194
|
|
|
168
195
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
196
|
+
* The boot's installer (its release removes THIS install only, in any order — `adapterSlot`), on `@ultimat3/jobs/adapter` and never the `.` barrel: the boot installs
|
|
197
|
+
* `postgresEventBus({ executor })` over its pool, and an app swaps it with `eventBus:` in
|
|
198
|
+
* `apps/<app>/runtime.ts`. The default above is one process's heap: the pod that publishes and the
|
|
199
|
+
* pod that resumes are never the same one, so a webhook landing on web-3 strands a run on worker-7
|
|
200
|
+
* until its 24h timeout dead-letters it, with nothing logged before then.
|
|
174
201
|
*/
|
|
175
|
-
export function
|
|
176
|
-
|
|
202
|
+
export function installEventBus(bus: EventBus): () => void {
|
|
203
|
+
return slot.install(bus);
|
|
177
204
|
}
|
|
178
205
|
|
|
179
206
|
export function eventBus(): EventBus {
|
|
180
|
-
return
|
|
207
|
+
return slot.current();
|
|
181
208
|
}
|
|
182
209
|
|
|
183
210
|
/**
|
|
@@ -187,7 +214,16 @@ export function eventBus(): EventBus {
|
|
|
187
214
|
* wait before that test has asked anything — call this between tests.
|
|
188
215
|
*/
|
|
189
216
|
export function resetEventBus(): void {
|
|
190
|
-
|
|
217
|
+
slot.reset();
|
|
218
|
+
base = memoryEventBus();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The per-test reset: a FRESH default bus under whatever is installed, which stays installed — a
|
|
223
|
+
* `beforeAll` swap survives every test while a test on the default still starts with no events.
|
|
224
|
+
*/
|
|
225
|
+
export function freshDefaultEventBus(): void {
|
|
226
|
+
base = memoryEventBus();
|
|
191
227
|
}
|
|
192
228
|
|
|
193
229
|
/** The one function app code calls to unblock a waiting step. */
|
|
@@ -196,5 +232,5 @@ export function publishEvent(
|
|
|
196
232
|
payload: unknown,
|
|
197
233
|
options?: PublishOptions,
|
|
198
234
|
): Promise<JobEvent> {
|
|
199
|
-
return
|
|
235
|
+
return slot.current().publish(name, payload, options);
|
|
200
236
|
}
|
package/src/export.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// and `backfill-pass.ts` already have.
|
|
17
17
|
|
|
18
18
|
import type { Clock, Ctx } from '@ultimat3/core';
|
|
19
|
-
import { assert,
|
|
19
|
+
import { assert, liveClock } from '@ultimat3/core';
|
|
20
20
|
import type { ReadBuilder } from '@ultimat3/entity';
|
|
21
21
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
22
22
|
import type { Pacer } from './backfill-rate';
|
|
@@ -171,7 +171,7 @@ export function exportRows<Row, I>(definition: ExportDefinition<Row, I>): JobHan
|
|
|
171
171
|
`export "${definition.name}" declares ${definition.columns.length} column(s) and ${new Set(definition.columns).size} distinct one(s)`,
|
|
172
172
|
`declare at least one column on exportRows("${definition.name}") and no duplicates — the list is the csv header and the ndjson key order, and a repeat writes the same cell twice`,
|
|
173
173
|
);
|
|
174
|
-
const clock = definition.clock ??
|
|
174
|
+
const clock = definition.clock ?? liveClock;
|
|
175
175
|
// Built at declaration for the reason `backfill()` builds its own there: the interval belongs to
|
|
176
176
|
// the table and the pool, not to whichever attempt holds the run.
|
|
177
177
|
const pace =
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The erase of a person is a sensitive mutation, so `subjectErase()` writes it to the `auditSink`
|
|
2
|
+
// port when one is installed: an `allowed` record per completed erase and a `failed` one per
|
|
3
|
+
// attempt that threw. The input is the subject's entity and the run id — never the key, which an
|
|
4
|
+
// app may make an email. At least once: `idempotencyKey` (`<job>:<runId>`) is what a sink dedupes on.
|
|
5
|
+
|
|
6
|
+
import type { AuditRecord, Ctx } from '@ultimat3/core';
|
|
7
|
+
import { AUDIT_SINK_FAILED_EVENT, auditFailureOf, getAuditSink, logger } from '@ultimat3/core';
|
|
8
|
+
import type { StepApi } from './steps-api';
|
|
9
|
+
|
|
10
|
+
export interface EraseAuditInput {
|
|
11
|
+
/** The job's durable name: the record's `name`. */
|
|
12
|
+
readonly job: string;
|
|
13
|
+
/** The subject's ENTITY name — which kind of person, never which one. */
|
|
14
|
+
readonly subject: string;
|
|
15
|
+
/** The run: what an auditor joins to `x jobs show`, and the sink's dedupe key. */
|
|
16
|
+
readonly runId: string;
|
|
17
|
+
readonly ctx: Ctx;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A job ran the erase — no action did — so `primitive: 'job'` on `surface: 'job'`. An `agentJob`
|
|
22
|
+
* write differs on purpose: there an action ran, so it records `'action'` on surface `'job'`.
|
|
23
|
+
*/
|
|
24
|
+
const recordOf = (input: EraseAuditInput, error: unknown, failed: boolean): AuditRecord => ({
|
|
25
|
+
at: input.ctx.now(),
|
|
26
|
+
name: input.job,
|
|
27
|
+
primitive: 'job',
|
|
28
|
+
mutator: false,
|
|
29
|
+
surface: 'job',
|
|
30
|
+
ctx: input.ctx,
|
|
31
|
+
input: { subject: input.subject, run: input.runId },
|
|
32
|
+
idempotencyKey: `${input.job}:${input.runId}`,
|
|
33
|
+
replayed: false,
|
|
34
|
+
outcome: failed ? 'failed' : 'allowed',
|
|
35
|
+
failure: failed ? auditFailureOf(error) : null,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** Run `erase`, then record it: `allowed` once it completed, `failed` for an attempt that threw. */
|
|
39
|
+
export async function auditedErase<R>(
|
|
40
|
+
input: EraseAuditInput,
|
|
41
|
+
step: StepApi,
|
|
42
|
+
erase: () => Promise<R>,
|
|
43
|
+
): Promise<R> {
|
|
44
|
+
let report: R;
|
|
45
|
+
try {
|
|
46
|
+
report = await erase();
|
|
47
|
+
} catch (error: unknown) {
|
|
48
|
+
// The erase's own error wins; a sink that also refuses is logged, as action's gate logs it.
|
|
49
|
+
const record = recordOf(input, error, true);
|
|
50
|
+
await Promise.resolve()
|
|
51
|
+
.then(() => getAuditSink()?.write(record))
|
|
52
|
+
.catch((sinkError: unknown) => {
|
|
53
|
+
logger.error(AUDIT_SINK_FAILED_EVENT, {
|
|
54
|
+
action: record.name,
|
|
55
|
+
outcome: record.outcome,
|
|
56
|
+
error: sinkError,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
const sink = getAuditSink();
|
|
62
|
+
// Its own step, so a replay after it landed writes nothing; a write that landed and then lost its
|
|
63
|
+
// checkpoint is written again under the same `idempotencyKey`. A sink that refuses on EVERY
|
|
64
|
+
// attempt can hold no record at all — the dead-lettered run (`x jobs show`) is the trail, and the
|
|
65
|
+
// refusal is logged here as action's audit gate logs it.
|
|
66
|
+
if (sink !== null) {
|
|
67
|
+
await step
|
|
68
|
+
.run('audit', async () => sink.write(recordOf(input, null, false)))
|
|
69
|
+
.catch((error: unknown) => {
|
|
70
|
+
logger.error(AUDIT_SINK_FAILED_EVENT, { action: input.job, outcome: 'allowed', error });
|
|
71
|
+
throw error;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return report;
|
|
75
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// What `personalData()` (entity's marker) adds to `defineSubject()`: proof the declared subjects
|
|
2
|
+
// cover every marked column. A subject's plan says WHICH tables it reaches and how each is treated;
|
|
3
|
+
// this says whether that treatment clears the marked columns, and whether every marked entity is
|
|
4
|
+
// reached at all. Pure — plans and marked entities in, refusals out — so a run and a check agree.
|
|
5
|
+
|
|
6
|
+
import type { PersonalDataEntity } from '@ultimat3/entity';
|
|
7
|
+
import { personalDataEntities, registeredEntities } from '@ultimat3/entity';
|
|
8
|
+
import { PersonalDataUnerasedError, PersonalDataUnreachedError } from './gdpr-errors';
|
|
9
|
+
import type { Subject } from './subject';
|
|
10
|
+
import { registeredSubjects } from './subject';
|
|
11
|
+
import type { SubjectPlan } from './subject-plan';
|
|
12
|
+
import { planSubject } from './subject-plan';
|
|
13
|
+
|
|
14
|
+
const markedOf = (
|
|
15
|
+
marked: readonly PersonalDataEntity[],
|
|
16
|
+
table: string,
|
|
17
|
+
): PersonalDataEntity | undefined => marked.find((each) => each.entity.$name === table);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Every reached table whose treatment would leave a marked column: `'delete'` clears the row,
|
|
21
|
+
* `{ retain }` is a stated reason (printed in the export's index), and `{ anonymize }` must name
|
|
22
|
+
* EVERY marked property — a value it overwrites is no longer the person's.
|
|
23
|
+
*/
|
|
24
|
+
export function unerasedPersonalData(
|
|
25
|
+
plan: SubjectPlan,
|
|
26
|
+
marked: readonly PersonalDataEntity[] = personalDataEntities(),
|
|
27
|
+
): readonly PersonalDataUnerasedError[] {
|
|
28
|
+
return plan.steps.flatMap((step) => {
|
|
29
|
+
const entry = markedOf(marked, step.entity);
|
|
30
|
+
const { treatment } = step;
|
|
31
|
+
if (entry === undefined || typeof treatment !== 'object' || !('anonymize' in treatment)) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
const left = entry.columns
|
|
35
|
+
.map((column) => column.property)
|
|
36
|
+
.filter((property) => !Object.hasOwn(treatment.anonymize, property));
|
|
37
|
+
return left.length === 0
|
|
38
|
+
? []
|
|
39
|
+
: [
|
|
40
|
+
new PersonalDataUnerasedError({
|
|
41
|
+
subject: plan.subject,
|
|
42
|
+
table: step.entity,
|
|
43
|
+
columns: left,
|
|
44
|
+
}),
|
|
45
|
+
];
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Every marked entity no plan reaches, in the registry's name order. */
|
|
50
|
+
export function unreachedPersonalData(
|
|
51
|
+
plans: readonly SubjectPlan[],
|
|
52
|
+
marked: readonly PersonalDataEntity[] = personalDataEntities(),
|
|
53
|
+
): readonly PersonalDataUnreachedError[] {
|
|
54
|
+
const reached = new Set(plans.flatMap((plan) => plan.steps.map((step) => step.entity)));
|
|
55
|
+
const subjects = plans.map((plan) => plan.subject);
|
|
56
|
+
return marked
|
|
57
|
+
.filter((entry) => !reached.has(entry.entity.$name))
|
|
58
|
+
.map(
|
|
59
|
+
(entry) =>
|
|
60
|
+
new PersonalDataUnreachedError({
|
|
61
|
+
table: entry.entity.$name,
|
|
62
|
+
columns: entry.columns.map((column) => column.property),
|
|
63
|
+
subjects,
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The whole check over this process's declarations — what `x exercise` lists, once every entity
|
|
70
|
+
* module has loaded. A subject whose own plan is refused (`X_SUBJECT_*`) raises that refusal first:
|
|
71
|
+
* coverage of an unplannable subject is not a question with an answer.
|
|
72
|
+
*/
|
|
73
|
+
export function personalDataProblems(
|
|
74
|
+
subjects: readonly Subject<never>[] = registeredSubjects(),
|
|
75
|
+
marked: readonly PersonalDataEntity[] = personalDataEntities(),
|
|
76
|
+
): readonly (PersonalDataUnerasedError | PersonalDataUnreachedError)[] {
|
|
77
|
+
// `planSubject` and not `subject-jobs.ts`'s `plannedSubject`: that one is where a run asks
|
|
78
|
+
// `unerasedPersonalData`, and importing it back would be a cycle.
|
|
79
|
+
const plans = subjects.map((subject) => {
|
|
80
|
+
const { plan, problems } = planSubject({
|
|
81
|
+
subject: subject.entity,
|
|
82
|
+
tables: subject.init.tables,
|
|
83
|
+
columns: subject.init.columns,
|
|
84
|
+
entries: registeredEntities(),
|
|
85
|
+
});
|
|
86
|
+
const [first] = problems;
|
|
87
|
+
if (first !== undefined) throw first;
|
|
88
|
+
return plan;
|
|
89
|
+
});
|
|
90
|
+
return [
|
|
91
|
+
...plans.flatMap((plan) => unerasedPersonalData(plan, marked)),
|
|
92
|
+
...unreachedPersonalData(plans, marked),
|
|
93
|
+
];
|
|
94
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The two `X_PERSONAL_DATA_*` refusals: a subject's erase that would leave a `personalData()` column
|
|
2
|
+
// standing, and a marked entity no subject reaches. Both are refusals of DECLARATIONS — the values
|
|
3
|
+
// printed are entity and column names, never a person's data.
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
import './error-codes';
|
|
7
|
+
|
|
8
|
+
/** A reached table's `{ anonymize }` does not name every marked column of it. */
|
|
9
|
+
export class PersonalDataUnerasedError extends UltimateError {
|
|
10
|
+
constructor(input: { subject: string; table: string; columns: readonly string[] }) {
|
|
11
|
+
const named = input.columns.map((column) => `${column}: null`).join(', ');
|
|
12
|
+
super({
|
|
13
|
+
code: 'X_PERSONAL_DATA_UNERASED',
|
|
14
|
+
cause: `defineSubject(${input.subject}) anonymizes ${input.table} without ${input.columns.join(', ')}, marked personalData() — an erase would leave the person's value in them`,
|
|
15
|
+
fix: `tables: { ${input.table}: { anonymize: { ${named} } } } # in defineSubject(${input.subject}, …), merged into the existing anonymize — or 'delete', or { retain: '<why it is kept>' }`,
|
|
16
|
+
meta: { subject: input.subject, table: input.table, columns: [...input.columns] },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** No declared subject's walk reaches an entity carrying marked columns. */
|
|
22
|
+
export class PersonalDataUnreachedError extends UltimateError {
|
|
23
|
+
constructor(input: { table: string; columns: readonly string[]; subjects: readonly string[] }) {
|
|
24
|
+
const by =
|
|
25
|
+
input.subjects.length === 0
|
|
26
|
+
? 'no subject is declared'
|
|
27
|
+
: `declared: ${input.subjects.join(', ')}`;
|
|
28
|
+
super({
|
|
29
|
+
code: 'X_PERSONAL_DATA_UNREACHED',
|
|
30
|
+
cause: `${input.table} marks ${input.columns.join(', ')} personalData() and no defineSubject() walk reaches it (${by}) — no export or erase would ever see them`,
|
|
31
|
+
fix: `defineSubject(${input.table}, { driver, key, tables: { ${input.table}: 'delete' } }) # in the module declaring subjects — or a reference to a subject's entity, or columns: { ${input.table}: '<key column>' } on an existing defineSubject`,
|
|
32
|
+
meta: { table: input.table, columns: [...input.columns], subjects: [...input.subjects] },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/import.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// what, and who may import, never do. The pass lives in `import-pass.ts`.
|
|
10
10
|
|
|
11
11
|
import type { Clock, Ctx } from '@ultimat3/core';
|
|
12
|
-
import { assert,
|
|
12
|
+
import { assert, liveClock } from '@ultimat3/core';
|
|
13
13
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
14
14
|
import type { Pacer } from './backfill-rate';
|
|
15
15
|
import { backfillPacer } from './backfill-rate';
|
|
@@ -146,7 +146,7 @@ export function importRows<Rec, I>(definition: ImportDefinition<Rec, I>): JobHan
|
|
|
146
146
|
`import "${name}" declares ${definition.columns.length} column(s) and ${new Set(definition.columns).size} distinct one(s)`,
|
|
147
147
|
`declare at least one column on importRows("${name}") and no duplicates — the list is what a csv header must name`,
|
|
148
148
|
);
|
|
149
|
-
const clock = definition.clock ??
|
|
149
|
+
const clock = definition.clock ?? liveClock;
|
|
150
150
|
const pace =
|
|
151
151
|
definition.rate === undefined
|
|
152
152
|
? undefined
|