@ultimat3/jobs 11.3.0 → 13.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.
@@ -0,0 +1,178 @@
1
+ // One `exportRows()` pass: the iteration, its durable checkpoints, and the manifest that closes
2
+ // the artifact. Split out of `export.ts` for the reason `backfill-pass.ts` is split out of
3
+ // `backfill.ts` — that file declares an export, this one runs it.
4
+ //
5
+ // THE PAGING IS `backfill-pass.ts`'s, not a second idiom. `inBatches()` reads the source one
6
+ // statement per page, each page runs inside its own `step.run`, and what a step persists is the
7
+ // CURSOR and two counters, never the page: `steps.ts` hands a completed step's output back for the
8
+ // whole run, so checkpointing rows would retain every row already exported until the job ended —
9
+ // which is the leak that turns an export of a large table into an OOM.
10
+ //
11
+ // THE PART KEY IS WHAT MAKES AT-LEAST-ONCE SAFE. A page is named by its INDEX, so a page that ran
12
+ // twice rewrites the same object with the same bytes. There is no idempotency argument to make
13
+ // about the app's rows and no append anywhere, because a duplicate part cannot be expressed.
14
+
15
+ import { assert, logger } from '@ultimat3/core';
16
+ import type { BatchIterator } from '@ultimat3/entity';
17
+ import { JobAbortedError } from './errors';
18
+ import type { ExportPlan, ExportReport } from './export';
19
+ import { ExportPartTooLargeError } from './export-errors';
20
+ import { encodeExportPage } from './export-format';
21
+ import type { ExportManifest } from './export-sink';
22
+ import { exportManifestKey, exportPartKey } from './export-sink';
23
+ import type { JobRunArgs } from './job';
24
+
25
+ /** Every page is a step, and this is the name it is checkpointed under. */
26
+ const STEP_PREFIX = 'page:';
27
+ /** The manifest is a step too, so a retry after a written manifest does not write a second one. */
28
+ const MANIFEST_STEP = 'manifest';
29
+
30
+ /** What a page persists: a bounded position and two counters. Never the page. See the header. */
31
+ interface Checkpoint {
32
+ /** Where the next page starts; `null` once the source is exhausted. */
33
+ readonly cursor: string | null;
34
+ readonly rows: number;
35
+ readonly bytes: number;
36
+ }
37
+
38
+ interface Iteration<Row> {
39
+ readonly batches: BatchIterator<Row>;
40
+ readonly pull: AsyncIterator<readonly Row[]>;
41
+ }
42
+
43
+ const fieldOf = (value: unknown, key: string): unknown =>
44
+ typeof value === 'object' && value !== null ? (value as Record<string, unknown>)[key] : undefined;
45
+
46
+ /**
47
+ * `steps.ts` hands a completed step's output back through an unchecked `as T`, so a checkpoint READ
48
+ * from storage is this shape by claim and never by check. Checked here because the failure is
49
+ * silent and expensive in both directions: an absent cursor is not `null`, so the loop would reopen
50
+ * the source at the top and export the whole table a second time into part 0 onward.
51
+ */
52
+ function asCheckpoint(value: unknown, step: string): Checkpoint {
53
+ const cursor = fieldOf(value, 'cursor');
54
+ const rows = fieldOf(value, 'rows');
55
+ const bytes = fieldOf(value, 'bytes');
56
+ assert(
57
+ (cursor === null || typeof cursor === 'string') &&
58
+ typeof rows === 'number' &&
59
+ typeof bytes === 'number',
60
+ `step "${step}" replayed ${JSON.stringify(value)}, which is not an export checkpoint`,
61
+ `x jobs show <jobId> --json prints the run's steps — a run id whose "${step}" was written by something other than this export has to be retired, not resumed`,
62
+ );
63
+ return { cursor, rows, bytes };
64
+ }
65
+
66
+ export async function exportPass<Row, I>(
67
+ plan: ExportPlan<Row, I>,
68
+ args: JobRunArgs<I>,
69
+ ): Promise<ExportReport> {
70
+ const { definition, size, maxPartBytes, pace } = plan;
71
+ const { step, runId, input, ctx } = args;
72
+ const name = definition.name;
73
+ const prefix = definition.prefix(input);
74
+ assert(
75
+ prefix.length > 0 && !prefix.startsWith('/') && !prefix.includes('..'),
76
+ `export "${name}" prefix() answered "${prefix}", which is not a key prefix`,
77
+ `return a relative, non-empty prefix from prefix() on exportRows("${name}") — "exports/<orgId>/<exportId>" is the shape, and a leading slash or a ".." reaches keys this export does not own`,
78
+ );
79
+
80
+ let cursor: string | null = null;
81
+ let rows = 0;
82
+ let bytes = 0;
83
+ let parts = 0;
84
+ let live: Iteration<Row> | undefined;
85
+
86
+ /**
87
+ * The iteration positioned at `cursor`, opened on the first page this attempt actually runs, so a
88
+ * resumed pass sends no statement for a page it already wrote. `batches.cursor` IS where the next
89
+ * statement starts, so comparing it with the checkpoint's is the whole staleness test.
90
+ */
91
+ const iterate = async (): Promise<Iteration<Row>> => {
92
+ if (live !== undefined && live.batches.cursor === cursor) return live;
93
+ await live?.batches.close();
94
+ const opened = definition.source({ input, ctx }).after(cursor).inBatches(size);
95
+ live = { batches: opened, pull: opened[Symbol.asyncIterator]() };
96
+ return live;
97
+ };
98
+
99
+ try {
100
+ for (let index = 0; ; index += 1) {
101
+ const stepName = `${STEP_PREFIX}${index}`;
102
+ const checkpoint = asCheckpoint(
103
+ await step.run(stepName, async (signal): Promise<Checkpoint> => {
104
+ // INSIDE the body, which is the whole of it: a completed step is served from storage
105
+ // without its body running, so an attempt resuming at page 500 replays 500 checkpoints
106
+ // and pays none of their pauses.
107
+ await pace?.wait({ signal, step: stepName });
108
+ const iteration = await iterate();
109
+ const next = await iteration.pull.next();
110
+ if (next.done === true) return { cursor: null, rows: 0, bytes: 0 };
111
+ const body = encodeExportPage({
112
+ subject: name,
113
+ format: definition.format,
114
+ columns: definition.columns,
115
+ records: next.value.map((record) => definition.row(record)),
116
+ // Part 0 only: the header belongs in the file once, and every line in both formats
117
+ // ends in a newline, so the parts concatenate into one valid file.
118
+ header: index === 0,
119
+ });
120
+ if (body.byteLength > maxPartBytes) {
121
+ throw new ExportPartTooLargeError({
122
+ export: name,
123
+ part: index,
124
+ bytes: body.byteLength,
125
+ maxBytes: maxPartBytes,
126
+ });
127
+ }
128
+ // Asked before the write and not only before the read: past the deadline this attempt no
129
+ // longer owns the run, and `steps.ts` would refuse the checkpoint anyway — so a `put`
130
+ // here is an object written by a worker whose successor is already re-running the page.
131
+ if (signal.aborted) throw new JobAbortedError({ job: name, step: stepName });
132
+ await definition.sink.put(exportPartKey(prefix, index, definition.format), body);
133
+ return {
134
+ cursor: iteration.batches.cursor,
135
+ rows: next.value.length,
136
+ bytes: body.byteLength,
137
+ };
138
+ }),
139
+ stepName,
140
+ );
141
+ rows += checkpoint.rows;
142
+ bytes += checkpoint.bytes;
143
+ cursor = checkpoint.cursor;
144
+ // `inBatches()` never yields an empty page, so `rows` is what tells a written part from the
145
+ // one step an exhausted source costs.
146
+ if (checkpoint.rows > 0) parts += 1;
147
+ if (cursor === null) break;
148
+ }
149
+ } finally {
150
+ // Whatever the iteration holds belongs to this attempt, and an attempt that failed, was
151
+ // cancelled or finished is done with it either way.
152
+ await live?.batches.close();
153
+ }
154
+
155
+ const manifestKey = exportManifestKey(prefix);
156
+ // A step of its own, so a retry after the manifest landed does not write a second one — and so
157
+ // that "the artifact is closed" is a checkpoint like every other fact this pass records.
158
+ await step.run(MANIFEST_STEP, async (): Promise<null> => {
159
+ const manifest: ExportManifest = {
160
+ export: name,
161
+ runId,
162
+ prefix,
163
+ format: definition.format,
164
+ columns: [...definition.columns],
165
+ parts,
166
+ rows,
167
+ bytes,
168
+ // `toISOString()` is UTC by definition and carries its zone in the string — the one date in
169
+ // this framework that needs no `timeZone` argument, because it names no locale's calendar.
170
+ completedAt: new Date(plan.nowMs()).toISOString(),
171
+ };
172
+ await definition.sink.put(manifestKey, new TextEncoder().encode(JSON.stringify(manifest)));
173
+ return null;
174
+ });
175
+
176
+ logger.info('jobs.export.completed', { export: name, prefix, parts, rows, bytes });
177
+ return { name, prefix, manifestKey, parts, rows, bytes };
178
+ }
@@ -0,0 +1,105 @@
1
+ // Where an export's bytes land, and what the artifact says about itself when it is finished.
2
+ //
3
+ // A SEAM and not a `@ultimat3/storage` import, for the reason `PurgeTarget` is one: this package
4
+ // holds no storage dependency, and taking one so a queue could name a disk would put the object
5
+ // store on tier 3's import graph. A `StorageDriver` satisfies this by having the method it already
6
+ // has — `put(key, body, options?)` with a `Uint8Array` as a legal `StorageBody`
7
+ // (`packages/storage/src/driver.ts:117`) — so `disk('exports')` IS an `ExportSink`.
8
+
9
+ import { assert } from '@ultimat3/core';
10
+ import type { ExportFormat } from './export-format';
11
+ import { EXPORT_EXTENSION } from './export-format';
12
+
13
+ /**
14
+ * One object, written whole.
15
+ *
16
+ * ONE OBJECT PER PAGE is the design, not a simplification. `put()` buffers by construction — its
17
+ * own header says the server-side path "is for objects that FIT IN MEMORY" — so an export that
18
+ * wrote one object would hold the entire dataset, which is the exact failure this factory exists
19
+ * to prevent. A page is bounded, so a page-sized object is bounded, and the artifact is the parts
20
+ * concatenated: both formats end every line in a newline so that concatenation is a valid file.
21
+ *
22
+ * It is also what makes at-least-once SAFE here. The key is derived from the page INDEX, so a
23
+ * replayed page rewrites the same key with the same bytes — an overwrite, never an append. No
24
+ * idempotency argument is needed about the app's rows, because a duplicate part is impossible to
25
+ * express.
26
+ */
27
+ export interface ExportSink {
28
+ /**
29
+ * `Promise<unknown>`, never `Promise<void>`. `StorageDriver.put` answers
30
+ * `Promise<StorageObject>` (`packages/storage/src/driver.ts:117`), and a `Promise<void>` return
31
+ * makes it **not assignable** — so this file's own header, `export.ts` and the README all claimed
32
+ * `disk('exports')` IS an `ExportSink` while the type refused the assignment. Measured:
33
+ * `TS2322: Type 'Promise<StorageObject>' is not assignable to type 'Promise<void>'`.
34
+ *
35
+ * The pass discards the answer either way; `unknown` is what says so without forbidding a driver
36
+ * from returning one. `scripts/export-sink.test.ts` compiles the assignment, because three
37
+ * comments asserting it and nothing checking it is what let this ship.
38
+ */
39
+ put(key: string, body: Uint8Array): Promise<unknown>;
40
+ }
41
+
42
+ /** Zero-padded so a lexical listing of the parts is their order. */
43
+ export const EXPORT_PART_DIGITS = 5;
44
+
45
+ export const exportPartKey = (prefix: string, index: number, format: ExportFormat): string =>
46
+ `${prefix}/part-${String(index).padStart(EXPORT_PART_DIGITS, '0')}.${EXPORT_EXTENSION[format]}`;
47
+
48
+ export const exportManifestKey = (prefix: string): string => `${prefix}/manifest.json`;
49
+
50
+ /**
51
+ * What the artifact says about itself. A COUNT of parts and never a list of them: the keys are
52
+ * derived from the index, so `exportPartKey(prefix, i, format)` rebuilds every one — and a list
53
+ * would be the one thing in this whole pass that grows with the size of the export.
54
+ */
55
+ export interface ExportManifest {
56
+ readonly export: string;
57
+ readonly runId: string;
58
+ readonly prefix: string;
59
+ readonly format: ExportFormat;
60
+ readonly columns: readonly string[];
61
+ readonly parts: number;
62
+ readonly rows: number;
63
+ readonly bytes: number;
64
+ /** ISO 8601, UTC. The one instant an export has, and it is not a date anybody formats. */
65
+ readonly completedAt: string;
66
+ }
67
+
68
+ export interface MemoryExportSink extends ExportSink {
69
+ /** Key -> the bytes last written under it. A rewritten part replaces, exactly as S3 would. */
70
+ objects(): ReadonlyMap<string, Uint8Array>;
71
+ /** Every `put` in order, key and length — a rewrite appears twice, which is what proves replay. */
72
+ writes(): readonly { readonly key: string; readonly bytes: number }[];
73
+ reset(): void;
74
+ }
75
+
76
+ /**
77
+ * The dev and test sink, and honest about being one: one heap, nothing survives a restart. It is
78
+ * deliberately UNBOUNDED, unlike `memoryWebhookLedger`'s ring — a sink that silently dropped parts
79
+ * would make a test of "the artifact is complete" pass over an artifact that is not.
80
+ */
81
+ export function memoryExportSink(
82
+ options: { readonly onPut?: (key: string, body: Uint8Array) => void } = {},
83
+ ): MemoryExportSink {
84
+ const objects = new Map<string, Uint8Array>();
85
+ const writes: { key: string; bytes: number }[] = [];
86
+ return {
87
+ put(key: string, body: Uint8Array): Promise<void> {
88
+ assert(
89
+ key.length > 0,
90
+ 'an export sink was handed an empty key',
91
+ 'return a non-empty prefix from prefix() on the exportRows() declaration',
92
+ );
93
+ options.onPut?.(key, body);
94
+ objects.set(key, body);
95
+ writes.push({ key, bytes: body.byteLength });
96
+ return Promise.resolve();
97
+ },
98
+ objects: (): ReadonlyMap<string, Uint8Array> => new Map(objects),
99
+ writes: (): readonly { readonly key: string; readonly bytes: number }[] => [...writes],
100
+ reset(): void {
101
+ objects.clear();
102
+ writes.length = 0;
103
+ },
104
+ };
105
+ }
package/src/export.ts ADDED
@@ -0,0 +1,187 @@
1
+ // `exportRows()` — a paged read streamed to object storage with a resumable cursor, declared as a
2
+ // `job` and NOT as a ninth primitive. An export is durable background work with an input schema, a
3
+ // retry policy, an idempotency key and a queue, which is the definition of a `job` — so this file
4
+ // is a FACTORY over `job()`, exactly as `backfill()`, `purge()` and `webhook()` are and as `llm()`
5
+ // is over `action()`. That is what gives an export `.enqueue()`, the worker's cancellation, the
6
+ // dead-letter path, `x jobs show` and a manifest row without a line here.
7
+ //
8
+ // WHY IT IS THE FRAMEWORK'S: the memory bound. Accumulating a result set before writing it is the
9
+ // failure this exists to prevent, and it is the failure every hand-rolled exporter starts with —
10
+ // `const all = await repo.all(); await disk.put(key, csv(all))` works on the developer's 200 rows
11
+ // and OOM-kills the pod on the customer's two million. What ships is the paging, the checkpoint,
12
+ // the one-object-per-page artifact and the csv the reviewer's spreadsheet will not execute. What
13
+ // never ships is which columns leave the building, or who may ask for them.
14
+ //
15
+ // The declaration lives here and the pass lives in `export-pass.ts` — the same split `backfill.ts`
16
+ // and `backfill-pass.ts` already have.
17
+
18
+ import type { Clock, Ctx } from '@ultimat3/core';
19
+ import { assert, systemClock } from '@ultimat3/core';
20
+ import type { ReadBuilder } from '@ultimat3/entity';
21
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
22
+ import type { Pacer } from './backfill-rate';
23
+ import { createPacer } from './backfill-rate';
24
+ import type { DurationInput } from './clock';
25
+ import { nowMs } from './clock';
26
+ import type { ExportFormat, ExportRecord } from './export-format';
27
+ import { EXPORT_FORMATS } from './export-format';
28
+ import { exportPass } from './export-pass';
29
+ import type { ExportSink } from './export-sink';
30
+ import type { JobHandle } from './job';
31
+ import { job } from './job';
32
+ import type { RetryPolicy } from './retry';
33
+ import { DEFAULT_RETRY } from './retry';
34
+ import type { JobTenant } from './tenant';
35
+
36
+ /**
37
+ * Rows per statement, per durable step and per part. The same reasoning `DEFAULT_BACKFILL_BATCH`
38
+ * carries: one statement's worth of work a worker can finish inside a lease. Smaller than a
39
+ * backfill's because every page here is also an OBJECT — a page is encoded whole before it is
40
+ * written, so the page size is the heap bound as well as the statement size.
41
+ */
42
+ export const DEFAULT_EXPORT_BATCH = 500;
43
+
44
+ /**
45
+ * The largest a single part may encode to. A heap bound, not a file-size preference: a page of 500
46
+ * rows that are each 2 KB is a megabyte held at once, and a row that is a megabyte of JSON turns
47
+ * the same page into half a gigabyte. Past it the pass refuses (`X_EXPORT_PART_TOO_LARGE`) instead
48
+ * of finding out on a pod with a memory limit.
49
+ */
50
+ export const DEFAULT_EXPORT_MAX_PART_BYTES = 8 * 1_024 * 1_024;
51
+
52
+ export interface ExportDefinition<Row, I> {
53
+ /**
54
+ * REQUIRED, unlike a job's. An export's name is a durable key — the queue row, the step trace and
55
+ * every part's own step all carry it — so it is never left to whichever export name a module used.
56
+ */
57
+ readonly name: string;
58
+ /**
59
+ * The payload, declared by the APP. Unlike `backfill()`, which takes no parameters, an export is
60
+ * always an export OF something: an org, a date range, a saved filter. A pointer, never a record
61
+ * — what to read is resolved from the app's own tables inside `source`.
62
+ */
63
+ readonly input: StandardSchemaV1<unknown, I>;
64
+ /**
65
+ * REQUIRED, exactly as on `job()`, and the security boundary of this whole feature: an export
66
+ * concentrates a tenant's rows into ONE downloadable object, so a pass that runs under the wrong
67
+ * org is the worst shape a cross-tenant read can take.
68
+ *
69
+ * `tenant: 'none'` STRIPS the org and every tenant-scoped read inside the pass then fails closed
70
+ * with `X_TENANCY_ACTOR_ORG_REQUIRED` — which is correct and is deliberately NOT relaxed.
71
+ * `backfill()` gets a cross-tenant scope opened for it (`backfill-scope.ts`) because its lazy
72
+ * chain leaves an author nothing to wrap; an export gets no such grant, and the difference is
73
+ * the direction of the data. A sweep that rewrites every tenant's rows is an operator action
74
+ * with an audit trail; an export that READS every tenant's rows writes them into one artifact
75
+ * somebody can download. Use `'none'` for a table that has no tenant column at all.
76
+ */
77
+ readonly tenant: JobTenant<I>;
78
+ /**
79
+ * The rows to export, as a chain: `({ input }) => db.orders.where({ orgId: input.orgId })`. Read
80
+ * once per attempt and never enqueued, so what a run exports cannot drift from what was declared.
81
+ * `orderBy` is optional — the driver's own total order ends in the primary key, which is what
82
+ * lets every page resume from the last one's cursor.
83
+ */
84
+ source(args: { readonly input: I; readonly ctx: Ctx }): ReadBuilder<Row>;
85
+ /** The artifact's key prefix: `<prefix>/part-00000.csv`, `<prefix>/manifest.json`. */
86
+ prefix(input: I): string;
87
+ readonly format: ExportFormat;
88
+ /**
89
+ * The columns, in order, and the app's alone. Both encoders project through this list, so a
90
+ * `row()` answering a key it does not carry is refused rather than dropped — which is what stops
91
+ * `row: (r) => ({ ...r })` shipping every column the entity gains from the next migration on.
92
+ */
93
+ readonly columns: readonly string[];
94
+ /** One row -> its cells. Where a date gets its explicit zone and a Money its currency. */
95
+ row(record: Row): ExportRecord;
96
+ /** Where the parts land. A `StorageDriver` satisfies this by having the method it already has. */
97
+ readonly sink: ExportSink;
98
+ /** Rows per statement, per step and per part. Defaults to `DEFAULT_EXPORT_BATCH`. */
99
+ readonly batch?: number;
100
+ /** Defaults to `DEFAULT_EXPORT_MAX_PART_BYTES`. */
101
+ readonly maxPartBytes?: number;
102
+ /**
103
+ * Pages per second. NO DEFAULT, which is where an export differs from a backfill: that one
104
+ * throttles by default because it competes for WRITE capacity on the rows the app is still
105
+ * serving, and this is a bounded read somebody is usually waiting for. Declare it for an export
106
+ * big enough to matter to the pool; there is no way to spell "unthrottled" except by omitting it.
107
+ */
108
+ readonly rate?: number;
109
+ /** The clock the manifest's `completedAt` and the pacer are read from. */
110
+ readonly clock?: Clock;
111
+ readonly queue?: string;
112
+ readonly retry?: RetryPolicy;
113
+ /** Per attempt, not per pass: a resumed attempt picks up at the last checkpoint. */
114
+ readonly timeout?: DurationInput;
115
+ }
116
+
117
+ /** What one completed pass reports — bounded, so `x jobs show` can print it. */
118
+ export interface ExportReport {
119
+ readonly name: string;
120
+ readonly prefix: string;
121
+ readonly manifestKey: string;
122
+ readonly parts: number;
123
+ readonly rows: number;
124
+ readonly bytes: number;
125
+ }
126
+
127
+ /** Everything fixed at declaration, validated and built once. Read by `exportPass`. */
128
+ export interface ExportPlan<Row, I> {
129
+ readonly definition: ExportDefinition<Row, I>;
130
+ readonly size: number;
131
+ readonly maxPartBytes: number;
132
+ /** Absent unless a `rate` was declared — see `ExportDefinition.rate`. */
133
+ readonly pace: Pacer | undefined;
134
+ nowMs(): number;
135
+ }
136
+
137
+ export function exportRows<Row, I>(definition: ExportDefinition<Row, I>): JobHandle<I> {
138
+ const size = definition.batch ?? DEFAULT_EXPORT_BATCH;
139
+ // Refused where it was written. `inBatches()` refuses the same number one statement in, which for
140
+ // an export means a dead-lettered job and a stack trace instead of a failing build.
141
+ assert(
142
+ Number.isSafeInteger(size) && size >= 1,
143
+ `export "${definition.name}" declares batch: ${String(size)} — a batch is a whole number of rows, at least one`,
144
+ `set batch: ${DEFAULT_EXPORT_BATCH} on exportRows("${definition.name}") — the rows one statement reads, one step checkpoints and one part holds`,
145
+ );
146
+ const maxPartBytes = definition.maxPartBytes ?? DEFAULT_EXPORT_MAX_PART_BYTES;
147
+ assert(
148
+ Number.isSafeInteger(maxPartBytes) && maxPartBytes >= 1,
149
+ `export "${definition.name}" declares maxPartBytes: ${String(maxPartBytes)}, which no part can fit in`,
150
+ `set maxPartBytes: ${DEFAULT_EXPORT_MAX_PART_BYTES} on exportRows("${definition.name}"), or leave it out — the number is the heap one page may occupy`,
151
+ );
152
+ assert(
153
+ (EXPORT_FORMATS as readonly string[]).includes(definition.format),
154
+ `export "${definition.name}" declares format: ${String(definition.format)}`,
155
+ `set format to one of ${EXPORT_FORMATS.join(' | ')} on exportRows("${definition.name}")`,
156
+ );
157
+ // A column list nothing can be projected through is an artifact with no data in it, and both
158
+ // encoders would happily write one — an empty csv row per source row, forever.
159
+ assert(
160
+ definition.columns.length > 0 && new Set(definition.columns).size === definition.columns.length,
161
+ `export "${definition.name}" declares ${definition.columns.length} column(s) and ${new Set(definition.columns).size} distinct one(s)`,
162
+ `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`,
163
+ );
164
+ const clock = definition.clock ?? systemClock;
165
+ // Built at declaration for the reason `backfill()` builds its own there: the interval belongs to
166
+ // the table and the pool, not to whichever attempt holds the run.
167
+ const pace =
168
+ definition.rate === undefined
169
+ ? undefined
170
+ : createPacer({ rate: definition.rate, job: definition.name, clock });
171
+
172
+ return job<I>({
173
+ name: definition.name,
174
+ input: definition.input,
175
+ // ONE live pass per artifact, derived rather than declared: two runs writing the same prefix
176
+ // race over every part key, and the loser's bytes win half the objects. It is also why there is
177
+ // no `idempotencyKey` on this declaration — a second spelling of "which artifact is this" is a
178
+ // second answer to the question the key exists to settle.
179
+ idempotencyKey: (input) => `${definition.name}:${definition.prefix(input)}`,
180
+ tenant: definition.tenant,
181
+ retry: definition.retry ?? DEFAULT_RETRY,
182
+ ...(definition.queue === undefined ? {} : { queue: definition.queue }),
183
+ ...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
184
+ run: (args) =>
185
+ exportPass<Row, I>({ definition, size, maxPartBytes, pace, nowMs: () => nowMs(clock) }, args),
186
+ });
187
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,23 @@
7
7
  // bypasses `defineApi`'s own result — the ambiguity axiom 1 exists to refuse.
8
8
  import './register';
9
9
 
10
+ // The wire format, RE-EXPORTED from `@ultimat3/core` and never re-declared: it is one module at
11
+ // the tier both halves can reach, because this package signs a delivery, `@ultimat3/http` verifies
12
+ // one, and neither may import the other. Re-exported here so a `job` file needs one import rather
13
+ // than two — the same reason `t` is re-exported above.
14
+ export type { WebhookMacInput, WebhookSigningInput } from '@ultimat3/core';
15
+ export {
16
+ isCanonicalWebhookField,
17
+ WEBHOOK_FIELD_MAX,
18
+ WEBHOOK_ID_HEADER,
19
+ WEBHOOK_SIGNATURE_HEADER,
20
+ WEBHOOK_SIGNATURE_VERSION,
21
+ WEBHOOK_TOPIC_HEADER,
22
+ webhookHeaders,
23
+ webhookMac,
24
+ webhookSignature,
25
+ webhookSigningString,
26
+ } from '@ultimat3/core';
10
27
  /** Re-exported so a `job`/`task` file needs one import, not two. Same object as schema's. */
11
28
  export type { Infer } from '@ultimat3/schema';
12
29
  export { t } from '@ultimat3/schema';
@@ -84,6 +101,7 @@ export type {
84
101
  QueueStats,
85
102
  } from './driver';
86
103
  export {
104
+ assertClaimQueues,
87
105
  DEFAULT_QUEUE,
88
106
  DEFAULT_VISIBILITY_TIMEOUT_MS,
89
107
  isJobState,
@@ -134,6 +152,7 @@ export type { JobErrorCode } from './errors';
134
152
  export {
135
153
  ActionJobUnbridgedError,
136
154
  CancelUnsupportedError,
155
+ ClaimQueuesEmptyError,
137
156
  ConcurrencyUnenforceableError,
138
157
  DriverUnavailableError,
139
158
  IdempotencyRequiredError,
@@ -159,6 +178,18 @@ export type { PgEventBusOptions } from './events-pg';
159
178
  export { createPgEventBus } from './events-pg';
160
179
  export type { ExecuteJobOptions, JobExecution, JobOutcome } from './execute';
161
180
  export { executeJob } from './execute';
181
+ export type { ExportDefinition, ExportReport } from './export';
182
+ export { DEFAULT_EXPORT_BATCH, DEFAULT_EXPORT_MAX_PART_BYTES, exportRows } from './export';
183
+ export { ExportPartTooLargeError, ExportRowInvalidError } from './export-errors';
184
+ export type { EncodePageInput, ExportFormat, ExportRecord, ExportValue } from './export-format';
185
+ export { csvHeader, EXPORT_EXTENSION, EXPORT_FORMATS, encodeExportPage } from './export-format';
186
+ export type { ExportManifest, ExportSink, MemoryExportSink } from './export-sink';
187
+ export {
188
+ EXPORT_PART_DIGITS,
189
+ exportManifestKey,
190
+ exportPartKey,
191
+ memoryExportSink,
192
+ } from './export-sink';
162
193
  export type {
163
194
  DeadLetterEntry,
164
195
  JobsManifest,
@@ -220,7 +251,6 @@ export {
220
251
  export { DEFAULT_OUTBOX_CLAIM_LEASE_MS } from './outbox-lease';
221
252
  export type { PgOutboxOptions } from './outbox-pg';
222
253
  export { createPgOutboxStore } from './outbox-pg';
223
-
224
254
  export type {
225
255
  PurgeDefinition,
226
256
  PurgeInput,
@@ -285,5 +315,26 @@ export { getTask, isTaskHandle, registeredTasks, resetTasks, task } from './task
285
315
  * "whose tenant is this", which is the thing this declaration exists to make singular.
286
316
  */
287
317
  export type { JobTenant } from './tenant';
318
+ export type {
319
+ WebhookDefinition,
320
+ WebhookDeliveryInput,
321
+ WebhookEndpoint,
322
+ WebhookEvent,
323
+ WebhookFetch,
324
+ WebhookReport,
325
+ } from './webhook';
326
+ export { DEFAULT_WEBHOOK_DISABLE_AFTER, WEBHOOK_CONTENT_TYPE, webhook } from './webhook';
327
+ export {
328
+ WebhookDeliveryFailedError,
329
+ WebhookDeliveryRejectedError,
330
+ WebhookDeliveryThrottledError,
331
+ WebhookEndpointDisabledError,
332
+ WebhookEndpointInvalidError,
333
+ WebhookEndpointUnknownError,
334
+ WebhookEventInvalidError,
335
+ WebhookEventUnknownError,
336
+ } from './webhook-errors';
337
+ export type { MemoryWebhookLedger, WebhookAttempt, WebhookLedger } from './webhook-ledger';
338
+ export { DEFAULT_MAX_WEBHOOK_ATTEMPTS, memoryWebhookLedger } from './webhook-ledger';
288
339
  export type { Worker, WorkerOptions, WorkerStats } from './worker';
289
340
  export { createWorker } from './worker';