@ultimat3/jobs 30.9.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.
Files changed (60) hide show
  1. package/CLAUDE.md +72 -107
  2. package/README.md +202 -55
  3. package/package.json +8 -7
  4. package/src/adapter.ts +37 -0
  5. package/src/backfill-ledger.ts +2 -2
  6. package/src/backfill-rate.ts +1 -0
  7. package/src/clock.ts +2 -2
  8. package/src/drain-wait.ts +1 -0
  9. package/src/driver-memory.ts +2 -2
  10. package/src/driver-pg-ddl.ts +19 -0
  11. package/src/driver-pg-event-wake-sql.ts +43 -22
  12. package/src/driver-pg-sql.ts +68 -27
  13. package/src/driver-pg.ts +11 -10
  14. package/src/driver-redis.ts +2 -2
  15. package/src/driver.ts +14 -8
  16. package/src/error-codes.ts +27 -0
  17. package/src/errors-concurrency.ts +1 -1
  18. package/src/errors.ts +1 -1
  19. package/src/events-cursor.ts +31 -0
  20. package/src/events-pg.ts +52 -6
  21. package/src/events.ts +72 -36
  22. package/src/export.ts +2 -2
  23. package/src/gdpr-audit.ts +75 -0
  24. package/src/gdpr-coverage.ts +94 -0
  25. package/src/gdpr-errors.ts +35 -0
  26. package/src/import.ts +2 -2
  27. package/src/index.ts +45 -206
  28. package/src/inspect-operator.ts +2 -2
  29. package/src/inspect.ts +1 -1
  30. package/src/job-instrument.ts +115 -0
  31. package/src/job.ts +41 -4
  32. package/src/jobs-retention.ts +23 -6
  33. package/src/leases.ts +13 -3
  34. package/src/ledger-book.ts +168 -0
  35. package/src/ledger-errors.ts +48 -0
  36. package/src/ledger.ts +145 -0
  37. package/src/limits.ts +57 -19
  38. package/src/lock-errors.ts +54 -0
  39. package/src/lock.ts +176 -0
  40. package/src/outbox-relay.ts +4 -0
  41. package/src/outbox.ts +3 -3
  42. package/src/partitions.ts +119 -0
  43. package/src/reindex-sweep.ts +94 -0
  44. package/src/reindex.ts +147 -0
  45. package/src/retry-classification.ts +3 -2
  46. package/src/retry-jitter.ts +32 -0
  47. package/src/retry.ts +13 -16
  48. package/src/scheduler.ts +10 -6
  49. package/src/steps-api.ts +44 -5
  50. package/src/steps-suspension.ts +34 -3
  51. package/src/steps-wait.ts +52 -4
  52. package/src/steps.ts +1 -0
  53. package/src/subject-jobs.ts +12 -4
  54. package/src/webhook.ts +2 -2
  55. package/src/worker-admit.ts +8 -5
  56. package/src/worker-drain-cutoff.ts +1 -0
  57. package/src/worker-loop.ts +28 -5
  58. package/src/worker-registry.ts +7 -5
  59. package/src/worker-run.ts +53 -9
  60. 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, logger, systemClock, uuidV7 } from '@ultimat3/core';
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 each publish; the stored one
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 ?? systemClock;
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
- for (const [id, event] of events) {
89
+ nextExpiry = Number.POSITIVE_INFINITY;
90
+ for (const event of events.values()) {
71
91
  if (event.expiresAt <= at) {
72
- events.delete(id);
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.keys().next();
129
+ const oldest = events.values().next();
102
130
  if (oldest.done === true) break;
103
- events.delete(oldest.value);
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 at or after `afterMs`, so a resumed step consumes events in
114
- * publication order rather than jumping to the newest one. Ties in one ms go to the earlier
115
- * publish (the Map's insertion order), and `afterId` seeks past that event in the same order.
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
- const cursor = afterId === undefined ? undefined : events.get(afterId);
120
- // Until the cursor's own event is passed, an event in its millisecond came before it.
121
- let passed = cursor === undefined;
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 events.values()) {
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 < afterMs) continue;
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.publishedAt < best.publishedAt) best = 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
- let ambientBus: EventBus = memoryEventBus();
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
- * **Install `postgresEventBus({ executor })` here in any deployment with more than one process.**
170
- * The default above is one process's heap: the pod that publishes and the pod that resumes are
171
- * never the same one, so a webhook landing on web-3 strands a run on worker-7 until its 24h
172
- * timeout dead-letters it, with nothing logged before then. This line used to promise a
173
- * "NATS/Redis-streams bus swapped at boot"; no such bus existed and boot installed the memory one.
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 setEventBus(bus: EventBus): void {
176
- ambientBus = bus;
202
+ export function installEventBus(bus: EventBus): () => void {
203
+ return slot.install(bus);
177
204
  }
178
205
 
179
206
  export function eventBus(): EventBus {
180
- return ambientBus;
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
- ambientBus = memoryEventBus();
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 ambientBus.publish(name, payload, options);
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, systemClock } from '@ultimat3/core';
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 ?? systemClock;
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, systemClock } from '@ultimat3/core';
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 ?? systemClock;
149
+ const clock = definition.clock ?? liveClock;
150
150
  const pace =
151
151
  definition.rate === undefined
152
152
  ? undefined