@ultimat3/jobs 29.3.2 → 30.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 (109) hide show
  1. package/CLAUDE.md +68 -89
  2. package/README.md +304 -10
  3. package/package.json +18 -6
  4. package/src/backfill-definition.ts +184 -0
  5. package/src/backfill-errors.ts +18 -2
  6. package/src/backfill-pass.ts +43 -40
  7. package/src/backfill-pending.ts +40 -17
  8. package/src/backfill-reader.ts +94 -0
  9. package/src/backfill-registry.ts +5 -0
  10. package/src/backfill-scope.ts +1 -2
  11. package/src/backfill-source.ts +113 -0
  12. package/src/backfill.ts +54 -135
  13. package/src/consumer-errors.ts +42 -0
  14. package/src/consumer-guard.ts +19 -0
  15. package/src/consumer-loop.ts +171 -0
  16. package/src/consumer-registry.ts +103 -0
  17. package/src/consumer-types.ts +81 -0
  18. package/src/consumer.ts +147 -0
  19. package/src/driver-memory-operator.ts +25 -2
  20. package/src/driver-memory.ts +35 -1
  21. package/src/driver-pg-claim-sql.ts +128 -0
  22. package/src/driver-pg-ddl.ts +11 -2
  23. package/src/driver-pg-demand-sql.ts +27 -0
  24. package/src/driver-pg-enqueue-many.ts +153 -0
  25. package/src/driver-pg-event-wake-sql.ts +51 -0
  26. package/src/driver-pg-operator-sql.ts +34 -6
  27. package/src/driver-pg-operator.ts +36 -2
  28. package/src/driver-pg-outbox-sql.ts +34 -0
  29. package/src/driver-pg-sql.ts +24 -91
  30. package/src/driver-pg.ts +17 -1
  31. package/src/driver-redis-refusal.ts +25 -0
  32. package/src/driver-redis-scripts.ts +79 -30
  33. package/src/driver-redis.ts +70 -42
  34. package/src/driver.ts +38 -1
  35. package/src/enqueue-record.ts +137 -0
  36. package/src/error-codes.ts +233 -0
  37. package/src/errors-concurrency.ts +2 -2
  38. package/src/errors-declaration.ts +15 -2
  39. package/src/errors-operator.ts +1 -1
  40. package/src/errors-requeue.ts +1 -1
  41. package/src/errors-saga.ts +87 -0
  42. package/src/errors-steps.ts +35 -0
  43. package/src/errors-tenant.ts +1 -1
  44. package/src/errors.ts +25 -165
  45. package/src/events-pg.ts +20 -8
  46. package/src/events.ts +14 -3
  47. package/src/execute-ctx.ts +51 -0
  48. package/src/execute-unwind.ts +191 -0
  49. package/src/execute.ts +50 -51
  50. package/src/export-errors.ts +1 -1
  51. package/src/failure-row.ts +43 -0
  52. package/src/import-errors.ts +102 -0
  53. package/src/import-format.ts +232 -0
  54. package/src/import-pass.ts +255 -0
  55. package/src/import-source.ts +49 -0
  56. package/src/import.ts +175 -0
  57. package/src/index.ts +163 -7
  58. package/src/inspect-operator.ts +24 -3
  59. package/src/inspect.ts +17 -4
  60. package/src/introspection.ts +24 -1
  61. package/src/job-enqueue-many.ts +108 -0
  62. package/src/job-types.ts +229 -0
  63. package/src/job.ts +19 -208
  64. package/src/metrics.ts +10 -0
  65. package/src/outbox-pg.ts +35 -0
  66. package/src/outbox-relay.ts +58 -19
  67. package/src/outbox.ts +65 -73
  68. package/src/purge-entities.ts +92 -0
  69. package/src/purge.ts +10 -2
  70. package/src/queue-wake.ts +1 -1
  71. package/src/refresh-view.ts +76 -0
  72. package/src/retry-classification.ts +27 -8
  73. package/src/retry.ts +26 -0
  74. package/src/rollup-errors.ts +27 -0
  75. package/src/rollup.ts +190 -0
  76. package/src/run-deadline.ts +11 -7
  77. package/src/scheduler.ts +2 -1
  78. package/src/settled.ts +56 -15
  79. package/src/step-resolve.ts +37 -0
  80. package/src/steps-api.ts +111 -0
  81. package/src/steps-core.ts +26 -0
  82. package/src/steps-output.ts +142 -0
  83. package/src/steps-store.ts +68 -0
  84. package/src/steps-suspension.ts +51 -0
  85. package/src/steps-timeout.ts +10 -5
  86. package/src/steps-unwind.ts +94 -0
  87. package/src/steps-wait.ts +163 -0
  88. package/src/steps.ts +174 -210
  89. package/src/subject-erase-pass.ts +134 -0
  90. package/src/subject-errors.ts +48 -0
  91. package/src/subject-export-pass.ts +228 -0
  92. package/src/subject-jobs.ts +121 -0
  93. package/src/subject-plan.ts +287 -0
  94. package/src/subject-select.ts +106 -0
  95. package/src/subject.ts +119 -0
  96. package/src/tenant.ts +69 -1
  97. package/src/type-pins.ts +90 -0
  98. package/src/webhook-errors.ts +1 -1
  99. package/src/webhook-receiver-errors.ts +59 -0
  100. package/src/webhook-receiver-route.ts +127 -0
  101. package/src/webhook-receiver-types.ts +97 -0
  102. package/src/webhook-receiver.ts +132 -0
  103. package/src/webhook-target.ts +20 -43
  104. package/src/webhook.ts +7 -4
  105. package/src/worker-queue-demand.ts +57 -0
  106. package/src/worker-queue-depth.ts +15 -3
  107. package/src/worker-registry.ts +4 -1
  108. package/src/worker-types.ts +2 -0
  109. package/src/worker.ts +2 -2
@@ -0,0 +1,102 @@
1
+ // The three `X_IMPORT_*` codes an `importRows()` pass can end on. The codes, titles and retry
2
+ // classes live in `error-codes.ts`; this file holds only the classes, for the reason
3
+ // `export-errors.ts` and `backfill-errors.ts` are split out.
4
+ //
5
+ // All three are TERMINAL: the same bytes answer the same way on every attempt.
6
+
7
+ import { UltimateError } from '@ultimat3/core';
8
+
9
+ const listed = (names: readonly string[]): string =>
10
+ names.length === 0 ? 'none' : names.map((name) => JSON.stringify(name)).join(', ');
11
+
12
+ /** At most this many undeclared names are shown, each cut to this many characters. */
13
+ export const IMPORT_HEADER_SHOWN = 5;
14
+ export const IMPORT_HEADER_NAME_CHARS = 40;
15
+
16
+ /**
17
+ * The ONE rule for a name that is FILE text — a header cell, an undeclared ndjson key — wherever
18
+ * it is reported: cut to `IMPORT_HEADER_NAME_CHARS`, so a 64 KiB cell cannot become a prompt for
19
+ * the agent reading `x jobs show`. Every reader JSON-escapes it (`shown`, the error report's
20
+ * `JSON.stringify`), so a newline in it cannot forge a line either.
21
+ */
22
+ export const fileText = (name: string): string =>
23
+ name.length > IMPORT_HEADER_NAME_CHARS ? `${name.slice(0, IMPORT_HEADER_NAME_CHARS)}…` : name;
24
+
25
+ const shown = (name: string): string => JSON.stringify(fileText(name));
26
+
27
+ /**
28
+ * A csv header that does not name exactly the declared columns. Both directions, as
29
+ * `encodeExportPage` refuses both: a missing column would arrive as nothing in every row, and an
30
+ * undeclared one is a column the app never agreed to read.
31
+ *
32
+ * `missing` is the DECLARATION's names and is printed whole. `undeclared` is the FILE's text, so
33
+ * it is a count plus a few escaped, truncated names, and the `fix:` carries none of it.
34
+ */
35
+ export class ImportHeaderMismatchError extends UltimateError {
36
+ constructor(input: {
37
+ import: string;
38
+ /** The declared columns: what the file's first line must name. */
39
+ columns: readonly string[];
40
+ missing: readonly string[];
41
+ undeclared: readonly string[];
42
+ }) {
43
+ const names = input.undeclared.slice(0, IMPORT_HEADER_SHOWN).map(shown);
44
+ const more = input.undeclared.length - names.length;
45
+ const undeclared =
46
+ names.length === 0 ? 'none' : `${names.join(', ')}${more > 0 ? ` and ${more} more` : ''}`;
47
+ super({
48
+ code: 'X_IMPORT_HEADER_MISMATCH',
49
+ cause: `import "${input.import}" read a csv header that is missing ${listed(input.missing)} and carries ${input.undeclared.length} undeclared column(s): ${undeclared}`,
50
+ fix: `columns: [${input.columns.map((name) => JSON.stringify(name)).join(', ')}] on importRows("${input.import}") is what the file's first line must name — rewrite the header to it, or declare the file's columns there, then enqueue it again with a new key`,
51
+ meta: {
52
+ missing: [...input.missing],
53
+ undeclaredCount: input.undeclared.length,
54
+ undeclared: names,
55
+ },
56
+ });
57
+ }
58
+ }
59
+
60
+ /**
61
+ * A record with no end inside `maxRecordBytes`. Refused rather than read on: an unclosed quote
62
+ * makes every newline after it part of one cell, and buffering until it closes is how one byte
63
+ * turns a bounded import into the whole file in memory.
64
+ */
65
+ export class ImportRecordTooLargeError extends UltimateError {
66
+ constructor(input: { import: string; offset: number; maxBytes: number }) {
67
+ super({
68
+ code: 'X_IMPORT_RECORD_TOO_LARGE',
69
+ cause: `import "${input.import}" found no record end within ${input.maxBytes} bytes of byte offset ${input.offset} — usually a quote opened and never closed`,
70
+ fix: `importRows({ name: "${input.import}", maxRecordBytes: ${input.maxBytes * 2}, … }) if one record really is that large (keep pageBytes above it) — otherwise repair the quote at byte offset ${input.offset} of the file`,
71
+ meta: { offset: input.offset, maxBytes: input.maxBytes },
72
+ });
73
+ }
74
+ }
75
+
76
+ /**
77
+ * More rows refused than `maxRowErrors` allows. The `batch` that crossed the ceiling is NOT handed
78
+ * to `handle`; every refusal so far is already written to the error parts this names.
79
+ */
80
+ export class ImportTooManyErrorsError extends UltimateError {
81
+ constructor(input: {
82
+ import: string;
83
+ rejected: number;
84
+ maxRowErrors: number;
85
+ rows: number;
86
+ errorPrefix: string;
87
+ parts: number;
88
+ }) {
89
+ super({
90
+ code: 'X_IMPORT_TOO_MANY_ERRORS',
91
+ cause: `import "${input.import}" refused ${input.rejected} row(s) and allows ${input.maxRowErrors}; ${input.rows} row(s) were handled before it stopped`,
92
+ fix: `x jobs show <id> --json, then read the ${input.parts} errors-*.ndjson part(s) under "${input.errorPrefix}", repair those lines in the file and enqueue importRows("${input.import}") again with a new key`,
93
+ meta: {
94
+ rejected: input.rejected,
95
+ maxRowErrors: input.maxRowErrors,
96
+ rows: input.rows,
97
+ errorPrefix: input.errorPrefix,
98
+ parts: input.parts,
99
+ },
100
+ });
101
+ }
102
+ }
@@ -0,0 +1,232 @@
1
+ // One window of an import's bytes -> its complete records and where the next window starts. Pure
2
+ // and synchronous, the twin of `export-format.ts`, so every hazard is testable without a queue.
3
+ //
4
+ // Records are found on BYTES, never on decoded text: a `\n` (0x0A) and a `"` (0x22) never occur
5
+ // inside a UTF-8 multi-byte sequence, so a boundary cut there cannot split a character, and the
6
+ // bytes after the last complete record are simply read again by the next window. No carry.
7
+
8
+ import type { ExportFormat } from './export-format';
9
+ import { fileText, ImportHeaderMismatchError, ImportRecordTooLargeError } from './import-errors';
10
+
11
+ const NEWLINE = 0x0a;
12
+ const RETURN = 0x0d;
13
+ const QUOTE = 0x22;
14
+ const COMMA = 0x2c;
15
+ const BOM = [0xef, 0xbb, 0xbf] as const;
16
+
17
+ /** Why a record was refused before its schema saw it. A rule name, never the record's text. */
18
+ export const IMPORT_REJECTIONS = [
19
+ 'utf8.invalid',
20
+ 'csv.quote_unclosed',
21
+ 'csv.quote_stray',
22
+ 'csv.field_count',
23
+ 'json.syntax',
24
+ 'json.not_object',
25
+ 'record.undeclared',
26
+ ] as const;
27
+ export type ImportRejection = (typeof IMPORT_REJECTIONS)[number];
28
+
29
+ /** One complete record: the physical line it starts on (1-based) and its text, or the refusal. */
30
+ export interface RawRecord {
31
+ readonly line: number;
32
+ /** `undefined` when the bytes are not UTF-8: refused per row, never a whole-file failure. */
33
+ readonly text: string | undefined;
34
+ }
35
+
36
+ export interface SplitInput {
37
+ readonly subject: string;
38
+ readonly format: ExportFormat;
39
+ /** The window, starting at `offset`. */
40
+ readonly bytes: Uint8Array;
41
+ readonly offset: number;
42
+ /** The physical line `bytes[0]` sits on, 1-based. */
43
+ readonly line: number;
44
+ /** True when the window reaches the end of the object: an unterminated tail is then a record. */
45
+ readonly final: boolean;
46
+ readonly maxRecordBytes: number;
47
+ /** Stop after this many records, so a page of tiny rows is still a bounded statement. */
48
+ readonly maxRecords: number;
49
+ }
50
+
51
+ export interface SplitWindow {
52
+ readonly records: readonly RawRecord[];
53
+ /** Absolute byte offset of the first byte not consumed — the next window's `offset`. */
54
+ readonly next: number;
55
+ readonly nextLine: number;
56
+ }
57
+
58
+ const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
59
+
60
+ const decode = (bytes: Uint8Array): string | undefined => {
61
+ try {
62
+ return utf8.decode(bytes);
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ };
67
+
68
+ const hasBom = (bytes: Uint8Array): boolean => BOM.every((byte, at) => bytes[at] === byte);
69
+
70
+ /**
71
+ * The complete records in one window. Quote-aware for csv (a newline inside quotes is data);
72
+ * CRLF and LF alike; a UTF-8 BOM at offset 0 is consumed and never part of the first cell; a blank
73
+ * line is skipped. A record longer than `maxRecordBytes` is refused, and so is a full window with
74
+ * no record end in it, because the next read would have to grow.
75
+ */
76
+ export function splitRecords(input: SplitInput): SplitWindow {
77
+ const { bytes } = input;
78
+ const records: RawRecord[] = [];
79
+ let start = input.offset === 0 && hasBom(bytes) ? BOM.length : 0;
80
+ let line = input.line;
81
+ let startLine = line;
82
+ let quoted = false;
83
+ // RFC 4180 quotes a FIELD: a `"` opens one only at a field's first byte, and inside one `""` is
84
+ // an escaped quote (close, then re-open at once). Anywhere else it is a literal `parseCsvRecord`
85
+ // refuses for the row alone — flipping on every quote swallowed every later row into one record.
86
+ let fieldStart = true;
87
+ let justClosed = false;
88
+
89
+ const take = (end: number): void => {
90
+ if (end - start > input.maxRecordBytes) {
91
+ throw new ImportRecordTooLargeError({
92
+ import: input.subject,
93
+ offset: input.offset + start,
94
+ maxBytes: input.maxRecordBytes,
95
+ });
96
+ }
97
+ const last = end > start && bytes[end - 1] === RETURN ? end - 1 : end;
98
+ if (last > start) records.push({ line: startLine, text: decode(bytes.subarray(start, last)) });
99
+ };
100
+
101
+ for (let at = start; at < bytes.length && records.length < input.maxRecords; at += 1) {
102
+ const byte = bytes[at];
103
+ if (input.format === 'csv') {
104
+ const reopens = byte === QUOTE && !quoted && (fieldStart || justClosed);
105
+ justClosed = byte === QUOTE && quoted;
106
+ if (justClosed) quoted = false;
107
+ else if (reopens) quoted = true;
108
+ fieldStart = !quoted && byte === COMMA;
109
+ }
110
+ if (byte !== NEWLINE) continue;
111
+ line += 1;
112
+ if (quoted) continue;
113
+ take(at);
114
+ start = at + 1;
115
+ startLine = line;
116
+ fieldStart = true;
117
+ }
118
+ if (input.final && records.length < input.maxRecords && start < bytes.length) {
119
+ take(bytes.length);
120
+ start = bytes.length;
121
+ startLine = line;
122
+ }
123
+ if (records.length === 0 && !input.final && bytes.length - start > input.maxRecordBytes) {
124
+ // No record end in more than `maxRecordBytes`: reading a longer window is the buffering this
125
+ // refuses. The pass holds every window LONGER than `maxRecordBytes`, so a window that took no
126
+ // record and consumed no blank line always lands here — the loop cannot stand still.
127
+ throw new ImportRecordTooLargeError({
128
+ import: input.subject,
129
+ offset: input.offset + start,
130
+ maxBytes: input.maxRecordBytes,
131
+ });
132
+ }
133
+ return { records, next: input.offset + start, nextLine: startLine };
134
+ }
135
+
136
+ /** One csv field list, RFC 4180: an unquoted empty cell is `null`, a quoted `""` is `''`. */
137
+ export function parseCsvRecord(
138
+ text: string,
139
+ ): { readonly cells: readonly (string | null)[] } | { readonly rejected: ImportRejection } {
140
+ const cells: (string | null)[] = [];
141
+ let at = 0;
142
+ for (;;) {
143
+ if (text[at] === '"') {
144
+ let value = '';
145
+ at += 1;
146
+ for (;;) {
147
+ const close = text.indexOf('"', at);
148
+ if (close < 0) return { rejected: 'csv.quote_unclosed' };
149
+ value += text.slice(at, close);
150
+ if (text[close + 1] === '"') {
151
+ value += '"';
152
+ at = close + 2;
153
+ continue;
154
+ }
155
+ at = close + 1;
156
+ break;
157
+ }
158
+ cells.push(value);
159
+ if (at === text.length) return { cells };
160
+ if (text[at] !== ',') return { rejected: 'csv.quote_stray' };
161
+ at += 1;
162
+ continue;
163
+ }
164
+ const comma = text.indexOf(',', at);
165
+ const value = comma < 0 ? text.slice(at) : text.slice(at, comma);
166
+ if (value.includes('"')) return { rejected: 'csv.quote_stray' };
167
+ cells.push(value === '' ? null : value);
168
+ if (comma < 0) return { cells };
169
+ at = comma + 1;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Page 0's first record against the declared columns, both directions, as `encodeExportPage`
175
+ * refuses both. Answers the file's column ORDER, which the rows are read by: a spreadsheet that
176
+ * moved a column is still the same file, and a renamed one is the refusal.
177
+ */
178
+ export function readCsvHeader(
179
+ subject: string,
180
+ columns: readonly string[],
181
+ record: RawRecord,
182
+ ): readonly string[] {
183
+ const parsed = record.text === undefined ? undefined : parseCsvRecord(record.text);
184
+ const header = parsed !== undefined && 'cells' in parsed ? parsed.cells.map((c) => c ?? '') : [];
185
+ const seen = new Set<string>();
186
+ const undeclared = header.filter((name) => {
187
+ const repeated = seen.has(name);
188
+ seen.add(name);
189
+ return repeated || !columns.includes(name);
190
+ });
191
+ const missing = columns.filter((name) => !seen.has(name));
192
+ if (missing.length > 0 || undeclared.length > 0 || header.length === 0) {
193
+ throw new ImportHeaderMismatchError({ import: subject, columns, missing, undeclared });
194
+ }
195
+ return header;
196
+ }
197
+
198
+ export type ImportRead =
199
+ | { readonly record: Readonly<Record<string, unknown>> }
200
+ | { readonly rejected: ImportRejection; readonly field: string | null };
201
+
202
+ /** One record's text -> a keyed object for the schema, or the format's refusal of it. */
203
+ export function readImportRecord(
204
+ format: ExportFormat,
205
+ columns: readonly string[],
206
+ text: string | undefined,
207
+ ): ImportRead {
208
+ if (text === undefined) return { rejected: 'utf8.invalid', field: null };
209
+ if (format === 'csv') {
210
+ const parsed = parseCsvRecord(text);
211
+ if ('rejected' in parsed) return { rejected: parsed.rejected, field: null };
212
+ if (parsed.cells.length !== columns.length) return { rejected: 'csv.field_count', field: null };
213
+ const record: Record<string, unknown> = {};
214
+ columns.forEach((column, at) => {
215
+ record[column] = parsed.cells[at] ?? null;
216
+ });
217
+ return { record };
218
+ }
219
+ let value: unknown;
220
+ try {
221
+ value = JSON.parse(text);
222
+ } catch {
223
+ return { rejected: 'json.syntax', field: null };
224
+ }
225
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
226
+ return { rejected: 'json.not_object', field: null };
227
+ }
228
+ const object = value as Record<string, unknown>;
229
+ const extra = Object.keys(object).find((key) => !columns.includes(key));
230
+ if (extra !== undefined) return { rejected: 'record.undeclared', field: fileText(extra) };
231
+ return { record: object };
232
+ }
@@ -0,0 +1,255 @@
1
+ // One `importRows()` pass: the page loop, its byte-offset checkpoints and the error report. Split
2
+ // out of `import.ts` as `export-pass.ts` is out of `export.ts` — that file declares, this runs.
3
+ //
4
+ // A PAGE IS A STEP. Its output — the next byte offset, the line, two counters, the error part and
5
+ // the csv header order — is the checkpoint, so a killed attempt resumes at the byte it stopped on.
6
+ // Never the rows: `steps.ts` hands every completed step's output back for the whole run.
7
+
8
+ import { assert, logger } from '@ultimat3/core';
9
+ import { coerceInput, formatPath, validateAsync } from '@ultimat3/schema';
10
+ import { JobAbortedError } from './errors';
11
+ import { EXPORT_PART_DIGITS } from './export-sink';
12
+ import type { ImportPlan, ImportReport } from './import';
13
+ import { ImportHeaderMismatchError, ImportTooManyErrorsError } from './import-errors';
14
+ import type { RawRecord } from './import-format';
15
+ import { readCsvHeader, readImportRecord, splitRecords } from './import-format';
16
+ import type { JobRunArgs } from './job';
17
+
18
+ const STEP_PREFIX = 'page:';
19
+ const STAT_STEP = 'stat';
20
+
21
+ /** `<prefix>/errors-00000.ndjson`, keyed by PAGE index, so a replayed page overwrites its part. */
22
+ export const importErrorKey = (prefix: string, page: number): string =>
23
+ `${prefix}/errors-${String(page).padStart(EXPORT_PART_DIGITS, '0')}.ndjson`;
24
+
25
+ /** One refused row. The line, the field and the rule — NEVER the value the app refused. */
26
+ export interface ImportRowError {
27
+ readonly line: number;
28
+ readonly field: string | null;
29
+ readonly code: string;
30
+ }
31
+
32
+ interface Checkpoint {
33
+ readonly offset: number;
34
+ readonly line: number;
35
+ readonly rows: number;
36
+ readonly rejected: number;
37
+ /** The page index whose error part this page wrote, or `null` for a clean page. */
38
+ readonly errorPart: number | null;
39
+ /** The csv header's column order once page 0 read it; carried, never re-read. */
40
+ readonly header: readonly string[] | null;
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
+ /** `steps.ts` replays through an unchecked `as T`; an offset read wrong re-imports from zero. */
47
+ function asCheckpoint(value: unknown, step: string): Checkpoint {
48
+ const offset = fieldOf(value, 'offset');
49
+ const line = fieldOf(value, 'line');
50
+ const rows = fieldOf(value, 'rows');
51
+ const rejected = fieldOf(value, 'rejected');
52
+ const errorPart = fieldOf(value, 'errorPart');
53
+ const header = fieldOf(value, 'header');
54
+ assert(
55
+ typeof offset === 'number' &&
56
+ typeof line === 'number' &&
57
+ typeof rows === 'number' &&
58
+ typeof rejected === 'number' &&
59
+ (errorPart === null || typeof errorPart === 'number') &&
60
+ (header === null || (Array.isArray(header) && header.every((h) => typeof h === 'string'))),
61
+ `step "${step}" replayed a value that is not an import checkpoint`,
62
+ `x jobs show <jobId> --json prints the run's steps — a run whose "${step}" was written by something other than this import has to be retired, not resumed`,
63
+ );
64
+ return { offset, line, rows, rejected, errorPart, header: header as readonly string[] | null };
65
+ }
66
+
67
+ interface Judged<Rec> {
68
+ readonly rows: Rec[];
69
+ readonly errors: ImportRowError[];
70
+ readonly rejected: number;
71
+ }
72
+
73
+ /** Every record of one page through the format and then the schema. */
74
+ async function judge<Rec, I>(
75
+ plan: ImportPlan<Rec, I>,
76
+ columns: readonly string[],
77
+ records: readonly RawRecord[],
78
+ ): Promise<Judged<Rec>> {
79
+ const { format, schema } = plan.definition;
80
+ const rows: Rec[] = [];
81
+ const errors: ImportRowError[] = [];
82
+ let rejected = 0;
83
+ for (const raw of records) {
84
+ const read = readImportRecord(format, columns, raw.text);
85
+ if ('rejected' in read) {
86
+ rejected += 1;
87
+ errors.push({ line: raw.line, field: read.field, code: read.rejected });
88
+ continue;
89
+ }
90
+ // A csv cell is text, exactly the HTTP boundary's problem; ndjson carries real JSON values.
91
+ const value = format === 'csv' ? coerceInput(schema, read.record) : read.record;
92
+ const result = await validateAsync(schema, value);
93
+ if (result.issues === undefined) {
94
+ rows.push(result.value);
95
+ continue;
96
+ }
97
+ rejected += 1;
98
+ for (const issue of result.issues) {
99
+ const field = formatPath(issue.path);
100
+ errors.push({
101
+ line: raw.line,
102
+ field: field === '' ? null : field,
103
+ code: issue.rule ?? 'schema.invalid',
104
+ });
105
+ }
106
+ }
107
+ return { rows, errors, rejected };
108
+ }
109
+
110
+ export async function importPass<Rec, I>(
111
+ plan: ImportPlan<Rec, I>,
112
+ args: JobRunArgs<I>,
113
+ ): Promise<ImportReport> {
114
+ const { definition, batch, pageBytes, maxRecordBytes, maxRowErrors, pace } = plan;
115
+ const { step, input, ctx } = args;
116
+ const name = definition.name;
117
+ const key = definition.key(input);
118
+ const declaredPrefix = definition.errors.prefix(input);
119
+ assert(
120
+ declaredPrefix.length > 0 && !declaredPrefix.startsWith('/') && !declaredPrefix.includes('..'),
121
+ `import "${name}" errors.prefix() answered "${declaredPrefix}", which is not a key prefix`,
122
+ `return a relative, non-empty prefix from errors.prefix() on importRows("${name}") — "imports/<orgId>/<importId>" is the shape`,
123
+ );
124
+
125
+ // Under the RUN: parts are keyed by page index, so a second import of the same file under one
126
+ // prefix would leave the first run's later parts beside its own and read as one report.
127
+ const errorPrefix = `${declaredPrefix}/${args.runId}`;
128
+
129
+ // A step, so every attempt reads the file at the length the first one measured.
130
+ const size = await step.run(STAT_STEP, async (): Promise<number> => {
131
+ const object = await definition.source().stat(key);
132
+ assert(
133
+ object !== undefined,
134
+ `import "${name}" found no object at "${key}"`,
135
+ `upload the file to "${key}" before enqueueing importRows("${name}"), or fix key() on the declaration`,
136
+ );
137
+ return object.size;
138
+ });
139
+
140
+ let state: Checkpoint = {
141
+ offset: 0,
142
+ line: 1,
143
+ rows: 0,
144
+ rejected: 0,
145
+ errorPart: null,
146
+ header: null,
147
+ };
148
+ let rows = 0;
149
+ let rejected = 0;
150
+ let parts = 0;
151
+
152
+ for (let index = 0; state.offset < size; index += 1) {
153
+ const stepName = `${STEP_PREFIX}${index}`;
154
+ const at = state;
155
+ const body = async (signal: AbortSignal): Promise<Checkpoint> => {
156
+ await pace?.wait({ signal, step: stepName });
157
+ const last = Math.min(at.offset + pageBytes, size) - 1;
158
+ const { bytes } = await definition.source().get(key, { range: [at.offset, last] });
159
+ assert(
160
+ bytes.byteLength > 0,
161
+ `import "${name}" read no bytes at offset ${at.offset} of "${key}", which stat measured at ${size}`,
162
+ `x jobs show <jobId> --json, then re-upload "${key}" under a new key — the object changed under a running import`,
163
+ );
164
+ const final = at.offset + bytes.byteLength >= size;
165
+ // The WHOLE window is used, `batch` records at a time: `batch` bounds one `handle` call, never
166
+ // how much of a read is kept, or a file of tiny rows costs one full read per batch.
167
+ let cursor = at.offset;
168
+ let line = at.line;
169
+ let header = at.header;
170
+ let pageRows = 0;
171
+ let pageRejected = 0;
172
+ const errors: ImportRowError[] = [];
173
+ const report = async (): Promise<number | null> => {
174
+ if (errors.length === 0) return null;
175
+ const lines = errors.map((error) => `${JSON.stringify(error)}\n`).join('');
176
+ await definition.errors.sink().put(importErrorKey(errorPrefix, index), encode(lines));
177
+ return index;
178
+ };
179
+ while (cursor < at.offset + bytes.byteLength) {
180
+ const headerNeeded = definition.format === 'csv' && header === null;
181
+ const window = splitRecords({
182
+ subject: name,
183
+ format: definition.format,
184
+ bytes: bytes.subarray(cursor - at.offset),
185
+ offset: cursor,
186
+ line,
187
+ final,
188
+ maxRecordBytes,
189
+ maxRecords: batch + (headerNeeded ? 1 : 0),
190
+ });
191
+ if (window.next === cursor) break;
192
+ cursor = window.next;
193
+ line = window.nextLine;
194
+ let records = window.records;
195
+ if (headerNeeded && records[0] !== undefined) {
196
+ header = readCsvHeader(name, definition.columns, records[0]);
197
+ records = records.slice(1);
198
+ }
199
+ const judged = await judge(plan, header ?? definition.columns, records);
200
+ errors.push(...judged.errors);
201
+ if (rejected + pageRejected + judged.rejected > maxRowErrors) {
202
+ const errorPart = await report();
203
+ throw new ImportTooManyErrorsError({
204
+ import: name,
205
+ rejected: rejected + pageRejected + judged.rejected,
206
+ maxRowErrors,
207
+ rows: rows + pageRows,
208
+ errorPrefix,
209
+ parts: parts + (errorPart === null ? 0 : 1),
210
+ });
211
+ }
212
+ if (judged.rows.length > 0) {
213
+ // Past the deadline this attempt no longer owns the run; its successor re-runs the page.
214
+ if (signal.aborted) throw new JobAbortedError({ job: name, step: stepName });
215
+ await definition.handle({ rows: judged.rows, ctx, input });
216
+ }
217
+ pageRows += judged.rows.length;
218
+ pageRejected += judged.rejected;
219
+ }
220
+ assert(
221
+ cursor > at.offset,
222
+ `import "${name}" made no progress at offset ${at.offset} of "${key}"`,
223
+ `raise pageBytes above maxRecordBytes on importRows("${name}") — a window must be able to hold a whole record`,
224
+ );
225
+ return {
226
+ offset: cursor,
227
+ line,
228
+ rows: pageRows,
229
+ rejected: pageRejected,
230
+ errorPart: await report(),
231
+ header,
232
+ };
233
+ };
234
+ state = asCheckpoint(await step.run(stepName, body), stepName);
235
+ rows += state.rows;
236
+ rejected += state.rejected;
237
+ if (state.errorPart !== null) parts += 1;
238
+ }
239
+
240
+ // A file with no first line at all — empty, a BOM, blank lines — has no header to compare, and
241
+ // "zero rows imported" would be a success over a file that never said what it holds.
242
+ if (definition.format === 'csv' && state.header === null) {
243
+ throw new ImportHeaderMismatchError({
244
+ import: name,
245
+ columns: definition.columns,
246
+ missing: definition.columns,
247
+ undeclared: [],
248
+ });
249
+ }
250
+
251
+ logger.info('jobs.import.completed', { import: name, key, rows, rejected, parts, bytes: size });
252
+ return { name, key, errorPrefix, rows, rejected, parts, bytes: size };
253
+ }
254
+
255
+ const encode = (text: string): Uint8Array => new TextEncoder().encode(text);
@@ -0,0 +1,49 @@
1
+ // Where an import's bytes come from, read one window at a time.
2
+ //
3
+ // A SEAM and not a `@ultimat3/storage` import, for the reason `ExportSink` is one: jobs is tier 3
4
+ // and holds no storage dependency. A `StorageDriver` satisfies it by having the two methods it
5
+ // already has — `stat(key)` and `get(key, { range: [first, last] })` — so `disk('imports')` IS an
6
+ // `ImportSource`; `scripts/export-sink.test.ts` compiles that assignment.
7
+
8
+ /** What `stat` must know: the object's whole length, so the pass knows which window is the last. */
9
+ export interface ImportObject {
10
+ readonly size: number;
11
+ }
12
+
13
+ export interface ImportSource {
14
+ /** `undefined` when no object is at the key — `StorageDriver.stat`'s own answer. */
15
+ stat(key: string): Promise<ImportObject | undefined>;
16
+ /**
17
+ * The bytes `[first, last]`, BOTH inclusive and clamped to the object, as HTTP's `Range:` and
18
+ * `StorageDriver.get`'s `range` read. Never called without a range: the whole object is exactly
19
+ * what this seam exists not to hold.
20
+ */
21
+ get(
22
+ key: string,
23
+ options: { readonly range: readonly [first: number, last: number] },
24
+ ): Promise<{ readonly bytes: Uint8Array }>;
25
+ }
26
+
27
+ export interface MemoryImportSource extends ImportSource {
28
+ /** Every range asked for, in order — what a test reads to prove the pass never read whole. */
29
+ reads(): readonly (readonly [number, number])[];
30
+ }
31
+
32
+ /** The dev and test source: one key, one buffer, the same clamp a real disk applies. */
33
+ export function memoryImportSource(
34
+ objects: Readonly<Record<string, Uint8Array>>,
35
+ ): MemoryImportSource {
36
+ const reads: (readonly [number, number])[] = [];
37
+ return {
38
+ stat: (key) => {
39
+ const object = objects[key];
40
+ return Promise.resolve(object === undefined ? undefined : { size: object.byteLength });
41
+ },
42
+ get: (key, { range }) => {
43
+ reads.push([range[0], range[1]]);
44
+ const object = objects[key] ?? new Uint8Array();
45
+ return Promise.resolve({ bytes: object.slice(range[0], range[1] + 1) });
46
+ },
47
+ reads: () => [...reads],
48
+ };
49
+ }