@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/lock.ts ADDED
@@ -0,0 +1,176 @@
1
+ // `withLock(key, fn, { ttl })`: one holder fleet-wide for `fn`, held in the job driver's
2
+ // `LeaseStore` (one slot, `lock:<key>`) — the same rows `job.concurrency` counts, so a lock needs
3
+ // no table, no Redis and no second mechanism. Not a primitive: a helper a job or action body calls.
4
+ // The holder renews at a third of `ttl`; a lost lease aborts `fn`'s signal with `X_LOCK_LOST`.
5
+
6
+ import type { SleepClock } from '@ultimat3/core';
7
+ import { ambientClock, assert, logger, renderThrowable, uuidV7 } from '@ultimat3/core';
8
+ import type { DurationInput } from './clock';
9
+ import { finiteDurationMs, nowMs } from './clock';
10
+ import { jobDriver } from './driver';
11
+ import type { HeldLease, LeaseStore } from './leases';
12
+ import { LockLostError, LockNotAcquiredError, LockStoreMissingError } from './lock-errors';
13
+ import type { IntervalScheduler, RenewalTimer } from './renewal-timer';
14
+ import { startRenewalTimer } from './renewal-timer';
15
+
16
+ /** Between two acquire attempts while another holder has the key. Never above `wait`'s remainder. */
17
+ export const DEFAULT_LOCK_POLL_MS = 100;
18
+
19
+ export interface WithLockOptions {
20
+ /** REQUIRED. How long the key stays held if this process dies; renewed while `fn` runs. */
21
+ readonly ttl: DurationInput;
22
+ /** How long to keep trying before `X_LOCK_NOT_ACQUIRED`. Default: one `ttl`. `0`: one attempt. */
23
+ readonly wait?: DurationInput;
24
+ /** Between attempts. Default `DEFAULT_LOCK_POLL_MS`. */
25
+ readonly poll?: DurationInput;
26
+ /** Default: the installed job driver's `leases` (`postgresJobDriver` over `x_job_leases`). */
27
+ readonly store?: LeaseStore;
28
+ /** Default `ambientClock()`: the wait, the expiry bookkeeping and the token's timestamp. */
29
+ readonly clock?: SleepClock;
30
+ /** Gives up the WAIT (rejects with its reason); once held, it aborts `fn`'s signal too. */
31
+ readonly signal?: AbortSignal;
32
+ /** The renewal interval's seam — `renewal-timer.ts`'s, so a test renews when it says so. */
33
+ readonly schedule?: IntervalScheduler;
34
+ }
35
+
36
+ /** What `fn` holds: the key, the fencing token and the signal a lost lease aborts. */
37
+ export interface HeldLock {
38
+ readonly key: string;
39
+ /**
40
+ * The FENCING token: the store's number for this grant (`HeldLease.fence`), greater than every
41
+ * token any earlier holder of any key got — the database's counter, never a clock. Write it
42
+ * beside whatever `fn` changes and refuse a write carrying a smaller one: that is what stops a
43
+ * holder that paused past its `ttl` (GC, a frozen VM) from writing after the next one took over.
44
+ */
45
+ readonly token: number;
46
+ readonly signal: AbortSignal;
47
+ }
48
+
49
+ /** The lease key: its own prefix, so no lock ever shares a slot table with a job's cap. */
50
+ export const lockLeaseKey = (key: string): string => `lock:${encodeURIComponent(key)}`;
51
+
52
+ const storeFor = (key: string, options: WithLockOptions): LeaseStore => {
53
+ if (options.store !== undefined) return options.store;
54
+ const driver = jobDriver();
55
+ if (driver?.leases === undefined) throw new LockStoreMissingError({ key, driver: driver?.name });
56
+ return driver.leases;
57
+ };
58
+
59
+ async function acquire(
60
+ store: LeaseStore,
61
+ key: string,
62
+ token: string,
63
+ bounds: { ttlMs: number; waitMs: number; pollMs: number },
64
+ clock: SleepClock,
65
+ signal: AbortSignal | undefined,
66
+ sent: (atMs: number) => void,
67
+ ): Promise<HeldLease> {
68
+ const leaseKey = lockLeaseKey(key);
69
+ const deadline = nowMs(clock) + bounds.waitMs;
70
+ for (;;) {
71
+ signal?.throwIfAborted();
72
+ const sentAt = nowMs(clock);
73
+ const lease = await store.acquire(leaseKey, 1, bounds.ttlMs, token);
74
+ if (lease !== undefined) {
75
+ sent(sentAt);
76
+ return lease;
77
+ }
78
+ const left = deadline - nowMs(clock);
79
+ if (left <= 0) {
80
+ const holders = await store.holders(leaseKey);
81
+ throw new LockNotAcquiredError({ key, waitMs: bounds.waitMs, holders });
82
+ }
83
+ await clock.sleep(Math.min(bounds.pollMs, left), signal);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Run `fn` while this process alone holds `key` across the fleet, then release it. Refuses with
89
+ * `X_LOCK_NOT_ACQUIRED` when `wait` passes first. If the lease is lost mid-run, `fn`'s signal
90
+ * aborts and the call rejects with `X_LOCK_LOST` even when `fn` resolved: its work was not exclusive.
91
+ */
92
+ export async function withLock<T>(
93
+ key: string,
94
+ fn: (lock: HeldLock) => Promise<T> | T,
95
+ options: WithLockOptions,
96
+ ): Promise<T> {
97
+ const ttlMs = finiteDurationMs(options.ttl, 'withLock', 'ttl');
98
+ assert(
99
+ Number.isSafeInteger(ttlMs) && ttlMs >= 3,
100
+ `withLock(${JSON.stringify(key)}) declares ttl: ${String(options.ttl)} — renewed at a third of it, so at least 3ms`,
101
+ `withLock(${JSON.stringify(key)}, fn, { ttl: '30s' }) # in the calling file`,
102
+ );
103
+ const waitMs = Math.max(0, finiteDurationMs(options.wait ?? ttlMs, 'withLock', 'wait'));
104
+ const pollMs = Math.max(
105
+ 1,
106
+ finiteDurationMs(options.poll ?? DEFAULT_LOCK_POLL_MS, 'withLock', 'poll'),
107
+ );
108
+ const clock = options.clock ?? ambientClock();
109
+ const store = storeFor(key, options);
110
+ const holder = uuidV7(clock);
111
+ // Stamped BEFORE the acquire that won: the store dates the expiry at its statement.
112
+ let renewedAt = nowMs(clock);
113
+ const lease = await acquire(
114
+ store,
115
+ key,
116
+ holder,
117
+ { ttlMs, waitMs, pollMs },
118
+ clock,
119
+ options.signal,
120
+ (sentAt) => {
121
+ renewedAt = sentAt;
122
+ },
123
+ );
124
+
125
+ const controller = new AbortController();
126
+ const onOuterAbort = (): void => controller.abort(options.signal?.reason);
127
+ let lost: LockLostError | undefined;
128
+ let timer: RenewalTimer | undefined;
129
+ // Everything after the acquire is inside the `try`: a throw anywhere still hands the lease back.
130
+ try {
131
+ const token = lease.fence;
132
+ if (token === undefined)
133
+ throw new LockStoreMissingError({ key, driver: undefined, fence: true });
134
+ options.signal?.addEventListener('abort', onOuterAbort, { once: true });
135
+ // An abort that landed while the acquire that SUCCEEDED was in flight fired no listener.
136
+ if (options.signal?.aborted === true) controller.abort(options.signal.reason);
137
+ const lapsed = (): boolean => nowMs(clock) - renewedAt >= ttlMs;
138
+ const lose = (): void => {
139
+ lost ??= new LockLostError({ key, token, ttlMs });
140
+ if (!controller.signal.aborted) controller.abort(lost);
141
+ };
142
+ let renewing = false;
143
+ // `worker-fleet-slots.ts`'s renewal: a renew hung on a dead socket never settles, so the
144
+ // lapse is decided BEFORE the call, one renewal is in flight at a time, and a reply that
145
+ // lands after the expiry it would have extended is still a loss.
146
+ const renew = async (): Promise<void> => {
147
+ if (lost !== undefined || timer?.stopped() === true) return;
148
+ if (lapsed()) return lose();
149
+ if (renewing) return;
150
+ renewing = true;
151
+ const sentAt = nowMs(clock);
152
+ try {
153
+ const held = await store.renew(lease, ttlMs);
154
+ if (timer?.stopped() === true) return;
155
+ if (held === false || lapsed()) return lose();
156
+ renewedAt = sentAt;
157
+ } catch (error: unknown) {
158
+ logger.warn('jobs.lock.renew_failed', { key, error: renderThrowable(error) });
159
+ if (timer?.stopped() !== true && lapsed()) lose();
160
+ } finally {
161
+ renewing = false;
162
+ }
163
+ };
164
+ timer = startRenewalTimer(Math.max(1, Math.floor(ttlMs / 3)), renew, options.schedule);
165
+ const result = await fn({ key, token, signal: controller.signal });
166
+ if (lost !== undefined) throw lost;
167
+ return result;
168
+ } finally {
169
+ timer?.stop();
170
+ options.signal?.removeEventListener('abort', onOuterAbort);
171
+ // Released only by this holder (the store checks `holder`); a failure leaves it to the TTL.
172
+ await store.release(lease).catch((error: unknown) => {
173
+ logger.warn('jobs.lock.release_failed', { key, error: renderThrowable(error) });
174
+ });
175
+ }
176
+ }
@@ -188,6 +188,10 @@ export function outboxRelay(options: RelayOptions): OutboxRelay {
188
188
  // The loop's next wait, decided by the pass itself so `tick()` and the timer agree: the floor
189
189
  // while rows are moving, doubling to the ceiling while there are none.
190
190
  delayMs = backoff.next(batch.length > 0);
191
+ // A FULL batch, all of it published, says there is more behind it: the next pass now, as the
192
+ // worker's `again`. At the floor a 500-row batch every 200 ms capped the relay at 2,500 rows a
193
+ // second, a 100k backlog at 40 s. A batch that stopped at a failing row keeps the floor.
194
+ if (batch.length === batchSize && published === batchSize) delayMs = 0;
191
195
  return published;
192
196
  };
193
197
 
package/src/outbox.ts CHANGED
@@ -356,7 +356,7 @@ let ambient: JobsFacade | undefined;
356
356
  let fallback: JobsFacade | undefined;
357
357
 
358
358
  /**
359
- * Installed once at boot, next to `setJobDriver`, with the app's outbox store and its
359
+ * Installed once at boot, next to the job driver, with the app's outbox store and its
360
360
  * transaction accessor. `handle.enqueue()` then joins the caller's transaction wherever it is
361
361
  * called from — which is the only reason the outbox protects anything.
362
362
  */
@@ -373,7 +373,7 @@ export function jobsFacade(): JobsFacade {
373
373
  if (ambient !== undefined) return ambient;
374
374
  fallback ??= outboxJobsFacade(
375
375
  {
376
- // A getter, not a snapshot: `setJobDriver()` after the first enqueue is honoured, and a
376
+ // A getter, not a snapshot: a driver installed after the first enqueue is honoured, and a
377
377
  // missing driver is an error at the call rather than at import time.
378
378
  get driver(): JobDriver {
379
379
  const installed = jobDriver();
@@ -381,7 +381,7 @@ export function jobsFacade(): JobsFacade {
381
381
  throw new DriverUnavailableError({
382
382
  driver: 'none',
383
383
  cause: 'no queue driver is installed in this process',
384
- fix: 'call setJobDriver(memoryJobDriver()) before enqueuing, or setJobDriver(postgresJobDriver()) for a real queue',
384
+ fix: 'memoryJobDriver() # jobs: in apps/<app>/runtime.ts — or enqueue from a process the boot started (x dev, or the serve entry), which builds postgresJobDriver() over DATABASE_URL',
385
385
  });
386
386
  }
387
387
  return installed;
@@ -0,0 +1,119 @@
1
+ // `partitions()`: the maintenance every `entity({ partitionBy })` needs — partitions created ahead,
2
+ // expired ones dropped — as a FACTORY over `job()` plus a `.schedule` `task()` (default daily), the
3
+ // shape `consumer()`'s `.supervisor` has. Never a ninth primitive. The work is `@ultimat3/db`'s
4
+ // `maintainPartitions` (bounded lock waits, `detach … concurrently`), judged at THIS job's clock.
5
+
6
+ import type { Clock } from '@ultimat3/core';
7
+ import { assert, liveClock } from '@ultimat3/core';
8
+ import { db, maintainPartitions } from '@ultimat3/db';
9
+ import type { EntityDescription } from '@ultimat3/entity';
10
+ import { registeredEntities } from '@ultimat3/entity';
11
+ import { t } from '@ultimat3/schema';
12
+ import type { DurationInput } from './clock';
13
+ import { nowMs } from './clock';
14
+ import type { JobHandle } from './job';
15
+ import { job } from './job';
16
+ import type { RetryPolicy } from './retry';
17
+ import { DEFAULT_RETRY } from './retry';
18
+ import type { TaskHandle } from './task';
19
+ import { task } from './task';
20
+
21
+ /** 03:17 UTC daily: off the hour every other cron in a fleet picks, and well before month-end. */
22
+ export const DEFAULT_PARTITIONS_CRON = '17 3 * * *';
23
+
24
+ export const DEFAULT_PARTITIONS_NAME = 'partitions';
25
+
26
+ export interface PartitionsDefinition {
27
+ /** The job's durable name; the task is `<name>.schedule`. Default `DEFAULT_PARTITIONS_NAME`. */
28
+ readonly name?: string;
29
+ /** Default `DEFAULT_PARTITIONS_CRON`. Daily is enough for any interval but `day` with `ahead: 0`. */
30
+ readonly cron?: string;
31
+ /** Default `'UTC'`: partition bounds are UTC, so the schedule is too. */
32
+ readonly tz?: string;
33
+ /** The instant partitions are judged at — the framework clock, so a test can move it. */
34
+ readonly clock?: Clock;
35
+ readonly queue?: string;
36
+ readonly retry?: RetryPolicy;
37
+ readonly timeout?: DurationInput;
38
+ }
39
+
40
+ /** One partitioned table's maintenance: the partitions this run created and dropped. */
41
+ export interface PartitionsTableReport {
42
+ readonly table: string;
43
+ readonly created: readonly string[];
44
+ readonly dropped: readonly string[];
45
+ }
46
+
47
+ export interface PartitionsReport {
48
+ readonly tables: readonly PartitionsTableReport[];
49
+ }
50
+
51
+ /**
52
+ * The job's input. The schedule enqueues `{}`. Before a `backfill()` copies rows older than the
53
+ * table's first partition, enqueue it once with `from`: the oldest row's instant (ISO). That run
54
+ * creates every partition back to it and sweeps nothing, so the copy routes instead of `23514`.
55
+ */
56
+ export interface PartitionsInput {
57
+ readonly from?: string | undefined;
58
+ }
59
+
60
+ /** The maintenance job, with the `task()` that enqueues it — as `consumer()` carries `.supervisor`. */
61
+ export interface PartitionsJob extends JobHandle<PartitionsInput> {
62
+ readonly schedule: TaskHandle;
63
+ }
64
+
65
+ /** Every registered entity declaring `partitionBy`, described, in registry name order. */
66
+ export const partitionedEntities = (): readonly EntityDescription[] =>
67
+ registeredEntities()
68
+ .map((entry) => entry.describe())
69
+ .filter((described) => described.partition != null);
70
+
71
+ /** `from` as an instant, or refused where it was enqueued: a bad bound would create nothing. */
72
+ function fromOf(name: string, from: string | undefined): Date | undefined {
73
+ if (from === undefined) return undefined;
74
+ const at = new Date(from);
75
+ assert(
76
+ !Number.isNaN(at.getTime()),
77
+ `${name}: from "${from}" is no ISO instant`,
78
+ `enqueue ${name} with { from: '2025-01-01T00:00:00.000Z' } — the oldest row's instant`,
79
+ );
80
+ return at;
81
+ }
82
+
83
+ export function partitions(definition: PartitionsDefinition = {}): PartitionsJob {
84
+ const name = definition.name ?? DEFAULT_PARTITIONS_NAME;
85
+ const clock = definition.clock ?? liveClock;
86
+ const handle = job<PartitionsInput>({
87
+ name,
88
+ input: t.object({ from: t.string.optional() }),
89
+ // Partitions are tables, not any tenant's rows.
90
+ tenant: 'none',
91
+ // One live pass: a second tick while one runs is the same pass, and the SQL is idempotent anyway.
92
+ // A `from` run is a different pass: it must not fold into the schedule's.
93
+ idempotencyKey: (input) => (input.from === undefined ? name : `${name}:from:${input.from}`),
94
+ retry: definition.retry ?? DEFAULT_RETRY,
95
+ ...(definition.queue === undefined ? {} : { queue: definition.queue }),
96
+ ...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
97
+ async run({ input, step }): Promise<PartitionsReport> {
98
+ const from = fromOf(name, input.from);
99
+ const tables: PartitionsTableReport[] = [];
100
+ // Read per run, never at declaration: an entity module may load after this one.
101
+ for (const described of partitionedEntities()) {
102
+ // One step per table: a retry resumes at the table that failed, never re-dropping.
103
+ const report = await step.run(`partitions:${described.table}`, async () => {
104
+ const run = await maintainPartitions(db(), described, new Date(nowMs(clock)), { from });
105
+ return { table: described.table, created: run.created, dropped: run.dropped };
106
+ });
107
+ tables.push(report);
108
+ }
109
+ return { tables };
110
+ },
111
+ });
112
+ const schedule = task({
113
+ name: `${name}.schedule`,
114
+ cron: definition.cron ?? DEFAULT_PARTITIONS_CRON,
115
+ tz: definition.tz ?? 'UTC',
116
+ enqueue: () => [[handle, {}]],
117
+ });
118
+ return Object.assign(handle, { schedule });
119
+ }
@@ -0,0 +1,94 @@
1
+ // `reindex()`'s second phase: the documents an index holds whose row the source no longer reads —
2
+ // deleted while the index was down, deleted by a store that names no rows, or moved to another
3
+ // tenant. One `list` page at a time, each looked up by its key, the absent ones removed.
4
+
5
+ import type { Ctx } from '@ultimat3/core';
6
+ import type { EntityCore, ReadBuilder, SearchIndex, SearchListEntry } from '@ultimat3/entity';
7
+ // The barrel's leaves, never `@ultimat3/entity/adapter`: that drags the driver kit into ROLE=migrate.
8
+ import { kindOf, MAX_PAGE_SIZE, rowKey } from '@ultimat3/entity';
9
+
10
+ /** A row's place in the index: its tenant and its key, as one comparable string. */
11
+ const placeOf = (tenant: string | null, id: string): string => JSON.stringify([tenant, id]);
12
+
13
+ /** The key values an index id spells (`rowKey`): one, or a composite key's JSON array of them. */
14
+ const keyValues = (entity: EntityCore<unknown>, id: string): readonly string[] => {
15
+ if (entity.$primaryKey.length === 1) return [id];
16
+ try {
17
+ const parsed: unknown = JSON.parse(id);
18
+ return Array.isArray(parsed) && parsed.every((each) => typeof each === 'string') ? parsed : [];
19
+ } catch {
20
+ return [];
21
+ }
22
+ };
23
+
24
+ /**
25
+ * A key spelling as the column compares it: `rowKey` writes an `integer` by value and an instant
26
+ * in ISO, both as text — handed back as the number and the `Date` the column holds.
27
+ */
28
+ const typed = (entity: EntityCore<unknown>, property: string, spelled: string): unknown => {
29
+ const kind = kindOf(entity, property);
30
+ if (kind === 'integer') return Number(spelled);
31
+ if (kind === 'timestamptz') return new Date(spelled);
32
+ return spelled;
33
+ };
34
+
35
+ /**
36
+ * The places among `entries` the source still reads. One read per page: each key column `in` the
37
+ * values the page names — exact for a single key, a superset for a composite one, matched here.
38
+ */
39
+ const present = async <Row>(
40
+ entity: EntityCore<Row>,
41
+ source: ReadBuilder<Row>,
42
+ entries: readonly SearchListEntry[],
43
+ ): Promise<ReadonlySet<string>> => {
44
+ const core = entity as EntityCore<unknown>;
45
+ let chain = source;
46
+ for (const [position, property] of core.$primaryKey.entries()) {
47
+ const values = new Set(entries.flatMap((entry) => keyValues(core, entry.id)[position] ?? []));
48
+ if (values.size === 0) return new Set();
49
+ chain = chain.andWhere(
50
+ property as keyof Row & string,
51
+ 'in',
52
+ [...values].map((value) => typed(core, property, value)),
53
+ );
54
+ }
55
+ const tenantColumn = core.$tenantColumn;
56
+ const found = new Set<string>();
57
+ for await (const batch of chain.inBatches(MAX_PAGE_SIZE)) {
58
+ for (const row of batch) {
59
+ const tenant =
60
+ tenantColumn === null ? null : (row as Readonly<Record<string, unknown>>)[tenantColumn];
61
+ found.add(placeOf(typeof tenant === 'string' ? tenant : null, rowKey(core, row)));
62
+ }
63
+ }
64
+ return found;
65
+ };
66
+
67
+ /**
68
+ * Remove every entry of one `list` page whose row the source no longer reads. The tombstone sits
69
+ * ONE millisecond above the version listed, never at the sweep's own instant: a write that put the
70
+ * id back after the list is newer than that, and keeps its document.
71
+ */
72
+ export const sweepListed = async <Row>(args: {
73
+ readonly entity: EntityCore<Row>;
74
+ readonly source: (args: { readonly ctx: Ctx }) => ReadBuilder<Row>;
75
+ readonly ctx: Ctx;
76
+ readonly index: SearchIndex;
77
+ readonly entries: readonly SearchListEntry[];
78
+ }): Promise<number> => {
79
+ const { entity, entries, index } = args;
80
+ if (entries.length === 0) return 0;
81
+ const still = await present(entity, args.source({ ctx: args.ctx }), entries);
82
+ const gone = new Map<string, { tenant: string | null; version: number; ids: string[] }>();
83
+ for (const entry of entries) {
84
+ if (still.has(placeOf(entry.tenant, entry.id))) continue;
85
+ const at = JSON.stringify([entry.tenant, entry.version]);
86
+ const group = gone.get(at) ?? { tenant: entry.tenant, version: entry.version, ids: [] };
87
+ group.ids.push(entry.id);
88
+ gone.set(at, group);
89
+ }
90
+ for (const { tenant, version, ids } of gone.values()) {
91
+ await index.remove({ entity: entity.$name, tenant }, ids, version + 1);
92
+ }
93
+ return [...gone.values()].reduce((sum, group) => sum + group.ids.length, 0);
94
+ };
package/src/reindex.ts ADDED
@@ -0,0 +1,147 @@
1
+ // `reindex()`: rebuild a `SearchIndex` from the table, as a `backfill()` — so a FACTORY over a
2
+ // factory over `job()`, never a ninth primitive. Run after swapping the built-in Postgres index for
3
+ // Meilisearch or Typesense, or after the index lost data or missed deletes: every row through
4
+ // entity's ONE document builder (`searchDocumentOf`), then a sweep of what the table lost.
5
+
6
+ import type { Ctx, Environment } from '@ultimat3/core';
7
+ import { assert, finiteCount } from '@ultimat3/core';
8
+ import type { EntityCore, ReadBuilder, SearchIndex, SearchListEntry } from '@ultimat3/entity';
9
+ import {
10
+ MAX_PAGE_SIZE,
11
+ searchDocumentOf,
12
+ searchIndex,
13
+ searchVersionColumn,
14
+ } from '@ultimat3/entity';
15
+ import type { BackfillInput, BackfillPage } from './backfill';
16
+ import { backfill, DEFAULT_BACKFILL_BATCH } from './backfill';
17
+ import type { DurationInput } from './clock';
18
+ import type { JobHandle } from './job';
19
+ import { sweepListed } from './reindex-sweep';
20
+ import type { RetryPolicy } from './retry';
21
+ import type { JobTenant } from './tenant';
22
+ import { NO_JOB_TENANT } from './tenant';
23
+
24
+ export interface ReindexDefinition<Row> {
25
+ /** REQUIRED: the backfill's durable key — its ledger row and queue key carry it. */
26
+ readonly name: string;
27
+ /** The entity whose `.searchable()` columns are indexed. */
28
+ readonly entity: EntityCore<Row>;
29
+ /**
30
+ * The rows to index — the WHOLE table: `() => db.posts`. Read per page. The sweep removes every
31
+ * document whose row this does not read, so a narrowed source empties the index of the rest.
32
+ */
33
+ source(args: { readonly ctx: Ctx }): ReadBuilder<Row>;
34
+ /** As on `backfill()`: `'none'` for a sweep of every tenant, each document keeping its own. */
35
+ readonly tenant: JobTenant<BackfillInput>;
36
+ /** Default: the installed index (`runtime.ts`'s `searchIndex`, else Postgres' built-in). */
37
+ readonly index?: () => SearchIndex;
38
+ readonly batch?: number;
39
+ readonly rate?: number;
40
+ readonly queue?: string;
41
+ readonly retry?: RetryPolicy;
42
+ readonly timeout?: DurationInput;
43
+ readonly environments?: readonly Environment[];
44
+ }
45
+
46
+ /** True when at least one column of `entity` is declared `.searchable()`. */
47
+ const isSearchable = (entity: EntityCore<unknown>): boolean =>
48
+ Object.values(entity.$columns).some((column) => column.$meta.searchable !== undefined);
49
+
50
+ /** One item of the pass: a source row to index, or an index entry the sweep checks. */
51
+ type ReindexItem<Row> = { readonly row: Row } | { readonly entry: SearchListEntry };
52
+
53
+ const ROWS = 'rows:';
54
+ const SWEEP = 'sweep:';
55
+
56
+ /** The phase a checkpointed cursor names, and the position inside it (`null`: from the top). */
57
+ const phaseOf = (
58
+ cursor: string | null,
59
+ ): { readonly sweep: boolean; readonly at: string | null } => {
60
+ if (cursor?.startsWith(SWEEP) === true) {
61
+ const at = cursor.slice(SWEEP.length);
62
+ return { sweep: true, at: at === '' ? null : at };
63
+ }
64
+ const at = cursor?.slice(ROWS.length) ?? '';
65
+ return { sweep: false, at: at === '' ? null : at };
66
+ };
67
+
68
+ /** One batch of the source at `at`, and where the next starts — the sweep, once it is exhausted. */
69
+ async function rowsPage<Row>(
70
+ source: ReindexDefinition<Row>['source'],
71
+ ctx: Ctx,
72
+ at: string | null,
73
+ size: number,
74
+ ): Promise<BackfillPage<ReindexItem<Row>>> {
75
+ const batches = source({ ctx }).after(at).inBatches(size);
76
+ try {
77
+ const first = await batches[Symbol.asyncIterator]().next();
78
+ const rows = first.done === true ? [] : first.value;
79
+ const next = first.done === true ? null : batches.cursor;
80
+ return { rows: rows.map((row) => ({ row })), next: next === null ? SWEEP : `${ROWS}${next}` };
81
+ } finally {
82
+ await batches.close();
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Two phases, one `backfill()`, one durable step per page. First every source row's document is
88
+ * written — at least once per batch: each carries its row's `onUpdateNow()` version and the index
89
+ * keeps only newer, so a replayed batch is a no-op and a batch that read a row before a write (or
90
+ * before its delete's tombstone) cannot put the old text back. Then the SWEEP: every document the
91
+ * index `list`s whose row `source` no longer reads is removed (`reindex-sweep.ts`) — so `source` is
92
+ * the whole table, and after a pass the index holds what it reads and nothing else.
93
+ */
94
+ export function reindex<Row>(definition: ReindexDefinition<Row>): JobHandle<BackfillInput> {
95
+ const { entity } = definition;
96
+ assert(
97
+ isSearchable(entity as EntityCore<unknown>),
98
+ `reindex "${definition.name}" names entity ${entity.$name}, which has no .searchable() column — there is nothing to index`,
99
+ `title: text().searchable() # in entity('${entity.$name}'): mark the columns a search reads, or drop reindex "${definition.name}"`,
100
+ );
101
+ // At declaration, not at the first batch: a document without a version is one an index cannot
102
+ // order against a write, so the entity must declare its `onUpdateNow()` column first.
103
+ searchVersionColumn(entity as EntityCore<unknown>);
104
+ const index = definition.index ?? searchIndex;
105
+ // Screened here, before `backfill()` sees it: `size` also bounds the sweep's `list` page.
106
+ const size = finiteCount(
107
+ `reindex "${definition.name}"`,
108
+ 'batch',
109
+ definition.batch ?? DEFAULT_BACKFILL_BATCH,
110
+ 1,
111
+ );
112
+ const everyTenant = definition.tenant === NO_JOB_TENANT;
113
+ return backfill<ReindexItem<Row>>({
114
+ name: definition.name,
115
+ tenant: definition.tenant,
116
+ pages: async ({ ctx, cursor }) => {
117
+ const phase = phaseOf(cursor);
118
+ if (!phase.sweep) return rowsPage(definition.source, ctx, phase.at, size);
119
+ // A declared tenant sweeps its own documents; `'none'` every tenant's, under the pass's
120
+ // cross-tenant scope (`backfill-scope.ts`).
121
+ const page = await index().list({
122
+ entity: entity.$name,
123
+ ...(everyTenant ? {} : { tenant: ctx.actor?.orgId ?? null }),
124
+ after: phase.at,
125
+ limit: Math.min(size, MAX_PAGE_SIZE),
126
+ });
127
+ return {
128
+ rows: page.entries.map((entry) => ({ entry })),
129
+ next: page.next === null ? null : `${SWEEP}${page.next}`,
130
+ };
131
+ },
132
+ handle: async ({ rows, ctx }) => {
133
+ const documents = rows.flatMap((item) =>
134
+ 'row' in item ? [searchDocumentOf(entity, item.row)] : [],
135
+ );
136
+ if (documents.length > 0) await index().index(documents);
137
+ const entries = rows.flatMap((item) => ('entry' in item ? [item.entry] : []));
138
+ await sweepListed({ entity, source: definition.source, ctx, index: index(), entries });
139
+ },
140
+ ...(definition.batch === undefined ? {} : { batch: definition.batch }),
141
+ ...(definition.rate === undefined ? {} : { rate: definition.rate }),
142
+ ...(definition.queue === undefined ? {} : { queue: definition.queue }),
143
+ ...(definition.retry === undefined ? {} : { retry: definition.retry }),
144
+ ...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
145
+ ...(definition.environments === undefined ? {} : { environments: definition.environments }),
146
+ });
147
+ }
@@ -72,9 +72,10 @@ export function nextRetryForError(
72
72
  // on top is core's `jitterStatedDelay` — the one rule for a named delay — because an HTTP-date
73
73
  // hands every delivery to one receiver the SAME instant, and waking them together is the herd.
74
74
  const cap = finiteDurationMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay, 'retry', 'maxDelay');
75
- // `jitter: false` is the policy's own decision and is honoured: the bare floor.
75
+ // `jitter: 'none'` is the policy's own decision and is honoured: the bare floor. `'equal'` and
76
+ // `'full'` both take the one spread — a stated delay is already a floor, so none is dropped.
76
77
  const floor = Math.min(stated, cap);
77
- const jittered = (policy.jitter ?? DEFAULT_RETRY.jitter) === true;
78
+ const jittered = (policy.jitter ?? DEFAULT_RETRY.jitter) !== 'none';
78
79
  const delayMs = jittered ? jitterStatedDelay(floor, cap, random) : floor;
79
80
  return { ...decision, delayMs, stoppedBy: undefined, classification };
80
81
  }
@@ -0,0 +1,32 @@
1
+ // Single responsibility: refuse a `retry.jitter` that is not core's `JitterMode`, at `job()`. A v30
2
+ // boolean from JS or JSON config otherwise splits in two: `backoffDelay` reads `true` as no jitter,
3
+ // the stated-delay path (`retry-classification.ts`) as jitter — one declaration, two meanings.
4
+
5
+ import type { JitterMode } from '@ultimat3/core';
6
+ import { renderCauseValue, UltimateError } from '@ultimat3/core';
7
+
8
+ const MODES: readonly JitterMode[] = ['none', 'equal', 'full'];
9
+
10
+ /**
11
+ * The shipped `X_JOB_DECLARATION_INVALID`, not a code of its own: the same statement — this `job()`
12
+ * call cannot be seated as written — and the same repair, an edit to the declaration.
13
+ */
14
+ export class JobRetryJitterInvalidError extends UltimateError {
15
+ constructor(input: { job: string; declared: unknown }) {
16
+ // What a v30 boolean meant, so the fix names the exact replacement: true → 'equal', false → 'none'.
17
+ const mapped: JitterMode = input.declared === false ? 'none' : 'equal';
18
+ super({
19
+ code: 'X_JOB_DECLARATION_INVALID',
20
+ cause: `job "${input.job}" declares retry.jitter ${renderCauseValue(input.declared)}, which is not one of 'none' | 'equal' | 'full' — a v30 boolean would mean no jitter to the backoff and jitter to a stated delay`,
21
+ fix: `jitter: '${mapped}', # in job('${input.job}')'s retry — v30's boolean maps true → 'equal', false → 'none'; omitted is 'equal'`,
22
+ meta: { job: input.job, field: 'retry.jitter' },
23
+ });
24
+ }
25
+ }
26
+
27
+ /** `undefined` is the default; anything else must be a `JitterMode`, read as `unknown` on purpose. */
28
+ export function assertRetryJitter(job: string, jitter: unknown): void {
29
+ if (jitter === undefined) return;
30
+ if (MODES.some((mode) => mode === jitter)) return;
31
+ throw new JobRetryJitterInvalidError({ job, declared: jitter });
32
+ }