@firedrill-tools/unstructured 0.1.4

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 (72) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +275 -0
  3. package/firedrill/agent.target.json +16 -0
  4. package/firedrill/baseline.scenario.json +5 -0
  5. package/firedrill/conformance.suite.json +21 -0
  6. package/firedrill/overloaded.scenario.json +11 -0
  7. package/firedrill/rate-limited.scenario.json +11 -0
  8. package/firedrill/run-response-lost.scenario.json +11 -0
  9. package/firedrill/small-responses.scenario.json +21 -0
  10. package/firedrill/tight-limits.scenario.json +21 -0
  11. package/firedrill/tools/unstructured/behavior.mjs +69 -0
  12. package/firedrill/tools/unstructured/lib/connectors.mjs +68 -0
  13. package/firedrill/tools/unstructured/lib/errors.mjs +58 -0
  14. package/firedrill/tools/unstructured/lib/gzip.mjs +38 -0
  15. package/firedrill/tools/unstructured/lib/identity.mjs +19 -0
  16. package/firedrill/tools/unstructured/lib/ids.mjs +36 -0
  17. package/firedrill/tools/unstructured/lib/jobs-derive.mjs +92 -0
  18. package/firedrill/tools/unstructured/lib/multipart.mjs +171 -0
  19. package/firedrill/tools/unstructured/lib/pages.mjs +40 -0
  20. package/firedrill/tools/unstructured/lib/partition/chunk.mjs +243 -0
  21. package/firedrill/tools/unstructured/lib/partition/csv.mjs +85 -0
  22. package/firedrill/tools/unstructured/lib/partition/csvout.mjs +25 -0
  23. package/firedrill/tools/unstructured/lib/partition/elements.mjs +171 -0
  24. package/firedrill/tools/unstructured/lib/partition/email.mjs +269 -0
  25. package/firedrill/tools/unstructured/lib/partition/html-tokens.mjs +134 -0
  26. package/firedrill/tools/unstructured/lib/partition/html-util.mjs +99 -0
  27. package/firedrill/tools/unstructured/lib/partition/html.mjs +211 -0
  28. package/firedrill/tools/unstructured/lib/partition/index.mjs +122 -0
  29. package/firedrill/tools/unstructured/lib/partition/markdown.mjs +220 -0
  30. package/firedrill/tools/unstructured/lib/partition/other.mjs +118 -0
  31. package/firedrill/tools/unstructured/lib/partition/text.mjs +53 -0
  32. package/firedrill/tools/unstructured/lib/sha256.mjs +161 -0
  33. package/firedrill/tools/unstructured/lib/store.mjs +33 -0
  34. package/firedrill/tools/unstructured/lib/util.mjs +149 -0
  35. package/firedrill/tools/unstructured/lib/validate.mjs +115 -0
  36. package/firedrill/tools/unstructured/lib/wire-multipart.mjs +78 -0
  37. package/firedrill/tools/unstructured/lib/wire.mjs +154 -0
  38. package/firedrill/tools/unstructured/ops/connectors.mjs +129 -0
  39. package/firedrill/tools/unstructured/ops/jobs.mjs +83 -0
  40. package/firedrill/tools/unstructured/ops/nodes.mjs +107 -0
  41. package/firedrill/tools/unstructured/ops/partition.mjs +112 -0
  42. package/firedrill/tools/unstructured/ops/workflows.mjs +180 -0
  43. package/firedrill/tools/unstructured/unstructured.tool.json +4892 -0
  44. package/firedrill/unstructured-archivist.drill.json +68 -0
  45. package/firedrill/unstructured-chunking.drill.json +67 -0
  46. package/firedrill/unstructured-connectors.drill.json +121 -0
  47. package/firedrill/unstructured-denied.drill.json +58 -0
  48. package/firedrill/unstructured-fresh-actor.drill.json +68 -0
  49. package/firedrill/unstructured-overloaded.drill.json +51 -0
  50. package/firedrill/unstructured-partition-errors.drill.json +66 -0
  51. package/firedrill/unstructured-partition.drill.json +95 -0
  52. package/firedrill/unstructured-rate-limited.drill.json +66 -0
  53. package/firedrill/unstructured-revoked-key.drill.json +773 -0
  54. package/firedrill/unstructured-run-lost.drill.json +51 -0
  55. package/firedrill/unstructured-small-responses.drill.json +173 -0
  56. package/firedrill/unstructured-tight-limits.drill.json +203 -0
  57. package/firedrill/unstructured-workflows-jobs.drill.json +167 -0
  58. package/firedrill/world.json +1556 -0
  59. package/firedrill.json +5 -0
  60. package/package.json +52 -0
  61. package/starter.json +1114 -0
  62. package/test/conformance.mjs +37 -0
  63. package/test/flows/access.mjs +54 -0
  64. package/test/flows/chunking.mjs +95 -0
  65. package/test/flows/connectors.mjs +76 -0
  66. package/test/flows/errors.mjs +115 -0
  67. package/test/flows/faults.mjs +73 -0
  68. package/test/flows/partition.mjs +225 -0
  69. package/test/flows/workflows.mjs +123 -0
  70. package/test/hostile-gen.mjs +0 -0
  71. package/test/hostile.mjs +155 -0
  72. package/test/lib.mjs +113 -0
@@ -0,0 +1,53 @@
1
+ // Plain-text partitioning: form feeds separate pages, blank lines separate blocks, blocks are classified by rule.
2
+ import { classifyBlock, stripListMarker } from "./elements.mjs";
3
+
4
+ export const MAX_BLOCKS = 100000;
5
+
6
+ /** Splits text into blocks on blank lines (any whitespace-only line). Returns null when the block bound is exceeded. */
7
+ export function blocksOf(text) {
8
+ const blocks = [];
9
+ let current = [];
10
+ for (const rawLine of text.split("\n")) {
11
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
12
+ if (line.trim().length === 0) {
13
+ if (current.length > 0) {
14
+ blocks.push(current.join("\n"));
15
+ current = [];
16
+ if (blocks.length > MAX_BLOCKS) return null;
17
+ }
18
+ } else current.push(line);
19
+ }
20
+ if (current.length > 0) blocks.push(current.join("\n"));
21
+ return blocks.length > MAX_BLOCKS ? null : blocks;
22
+ }
23
+
24
+ /** Emits the elements of one page of plain text into the builder; returns false when the block bound is exceeded. */
25
+ export function emitPlainBlocks(builder, text) {
26
+ const blocks = blocksOf(text);
27
+ if (blocks === null) return false;
28
+ for (const block of blocks) {
29
+ // Once the builder has overflowed (element or byte bound) nothing more can be added; stop instead of classifying the rest.
30
+ if (builder.overflow) return true;
31
+ const type = classifyBlock(block);
32
+ if (type === "ListItem") {
33
+ for (const line of block.split("\n")) {
34
+ if (builder.overflow) return true;
35
+ builder.add("ListItem", stripListMarker(line).trim());
36
+ }
37
+ } else if (type === "Title") builder.add("Title", block.trim(), { category_depth: 0 });
38
+ else if (type === "Address") builder.add("Address", block.split("\n").map((l) => l.trim()).join("\n"));
39
+ else builder.add(type, block.split("\n").map((l) => l.trim()).join("\n"));
40
+ }
41
+ return true;
42
+ }
43
+
44
+ /** Whole plain-text document: pages separated by U+000C. */
45
+ export function partitionText(builder, text) {
46
+ const pages = text.split("\f");
47
+ for (let index = 0; index < pages.length; index += 1) {
48
+ if (builder.overflow) return { ok: true };
49
+ if (index > 0) builder.pageBreak();
50
+ if (!emitPlainBlocks(builder, pages[index])) return { ok: false, message: "File has too many text blocks" };
51
+ }
52
+ return { ok: true };
53
+ }
@@ -0,0 +1,161 @@
1
+ // Pure SHA-256 over UTF-8 text (hex digest). Used for deterministic element ids, so it runs once per element: the message is
2
+ // encoded straight into a reused byte buffer (lone surrogates become U+FFFD), the schedule and state live in reused typed
3
+ // arrays, and the digest is formatted through a lookup table. No allocation per block.
4
+
5
+ const K = new Int32Array([
6
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
7
+ 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
8
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
9
+ 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
10
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
11
+ 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
12
+ ]);
13
+ const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
14
+ const W = new Int32Array(64);
15
+ const H = new Int32Array(8);
16
+ let buffer = new Uint8Array(1024);
17
+
18
+ /** Writes the UTF-8 bytes of the concatenated `parts` plus SHA-256 padding into `buffer`; returns the padded length (a multiple of 64). */
19
+ function encode(parts) {
20
+ let chars = 0;
21
+ for (let p = 0; p < parts.length; p += 1) chars += parts[p].length;
22
+ const need = chars * 3 + 72;
23
+ if (buffer.length < need) buffer = new Uint8Array(Math.max(need, buffer.length * 2));
24
+ const out = buffer;
25
+ let n = 0;
26
+ for (let p = 0; p < parts.length; p += 1) {
27
+ const text = parts[p];
28
+ for (let i = 0; i < text.length; i += 1) {
29
+ let code = text.charCodeAt(i);
30
+ if (code < 0x80) {
31
+ out[n++] = code;
32
+ continue;
33
+ }
34
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
35
+ const low = text.charCodeAt(i + 1);
36
+ if (low >= 0xdc00 && low <= 0xdfff) {
37
+ code = 0x10000 + ((code - 0xd800) << 10) + (low - 0xdc00);
38
+ i += 1;
39
+ } else code = 0xfffd;
40
+ } else if (code >= 0xd800 && code <= 0xdfff) code = 0xfffd;
41
+ if (code < 0x800) {
42
+ out[n++] = 0xc0 | (code >> 6);
43
+ out[n++] = 0x80 | (code & 0x3f);
44
+ } else if (code < 0x10000) {
45
+ out[n++] = 0xe0 | (code >> 12);
46
+ out[n++] = 0x80 | ((code >> 6) & 0x3f);
47
+ out[n++] = 0x80 | (code & 0x3f);
48
+ } else {
49
+ out[n++] = 0xf0 | (code >> 18);
50
+ out[n++] = 0x80 | ((code >> 12) & 0x3f);
51
+ out[n++] = 0x80 | ((code >> 6) & 0x3f);
52
+ out[n++] = 0x80 | (code & 0x3f);
53
+ }
54
+ }
55
+ }
56
+ const bitLength = n * 8;
57
+ out[n++] = 0x80;
58
+ while (n % 64 !== 56) out[n++] = 0;
59
+ const high = Math.floor(bitLength / 0x100000000);
60
+ const low = bitLength >>> 0;
61
+ out[n++] = (high >>> 24) & 255;
62
+ out[n++] = (high >>> 16) & 255;
63
+ out[n++] = (high >>> 8) & 255;
64
+ out[n++] = high & 255;
65
+ out[n++] = (low >>> 24) & 255;
66
+ out[n++] = (low >>> 16) & 255;
67
+ out[n++] = (low >>> 8) & 255;
68
+ out[n++] = low & 255;
69
+ return n;
70
+ }
71
+
72
+ /**
73
+ * Hex SHA-256 of the UTF-8 encoding of `parts` joined (without building the joined string). A surrogate pair must not be split
74
+ * across two parts; callers separate parts with ASCII.
75
+ */
76
+ export function sha256Hex(...parts) {
77
+ digest(parts);
78
+ return hexOf(8);
79
+ }
80
+
81
+ /** The first 32 hex characters of `sha256Hex(a, NUL, b, NUL, c, NUL, d)` (element and chunk ids), without a rest array. */
82
+ export function sha256Id(a, b, c, d) {
83
+ ID_PARTS[0] = a;
84
+ ID_PARTS[2] = b;
85
+ ID_PARTS[4] = c;
86
+ ID_PARTS[6] = d;
87
+ digest(ID_PARTS);
88
+ ID_PARTS[0] = ID_PARTS[2] = ID_PARTS[4] = ID_PARTS[6] = "";
89
+ return hexOf(4);
90
+ }
91
+
92
+ const NUL = String.fromCharCode(0);
93
+ const ID_PARTS = ["", NUL, "", NUL, "", NUL, ""];
94
+
95
+ function hexOf(words) {
96
+ let hex = "";
97
+ for (let i = 0; i < words; i += 1) {
98
+ const word = H[i];
99
+ hex += HEX[(word >>> 24) & 255] + HEX[(word >>> 16) & 255] + HEX[(word >>> 8) & 255] + HEX[word & 255];
100
+ }
101
+ return hex;
102
+ }
103
+
104
+ /** Runs SHA-256 over the encoded `parts`, leaving the state words in `H`. */
105
+ function digest(parts) {
106
+ const length = encode(parts);
107
+ const bytes = buffer;
108
+ const w = W;
109
+ const h = H;
110
+ h[0] = 0x6a09e667;
111
+ h[1] = 0xbb67ae85;
112
+ h[2] = 0x3c6ef372;
113
+ h[3] = 0xa54ff53a;
114
+ h[4] = 0x510e527f;
115
+ h[5] = 0x9b05688c;
116
+ h[6] = 0x1f83d9ab;
117
+ h[7] = 0x5be0cd19;
118
+ for (let offset = 0; offset < length; offset += 64) {
119
+ for (let i = 0; i < 16; i += 1) {
120
+ const j = offset + (i << 2);
121
+ w[i] = (bytes[j] << 24) | (bytes[j + 1] << 16) | (bytes[j + 2] << 8) | bytes[j + 3];
122
+ }
123
+ for (let i = 16; i < 64; i += 1) {
124
+ const x = w[i - 15];
125
+ const y = w[i - 2];
126
+ const s0 = ((x >>> 7) | (x << 25)) ^ ((x >>> 18) | (x << 14)) ^ (x >>> 3);
127
+ const s1 = ((y >>> 17) | (y << 15)) ^ ((y >>> 19) | (y << 13)) ^ (y >>> 10);
128
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
129
+ }
130
+ let a = h[0];
131
+ let b = h[1];
132
+ let c = h[2];
133
+ let d = h[3];
134
+ let e = h[4];
135
+ let f = h[5];
136
+ let g = h[6];
137
+ let hh = h[7];
138
+ for (let i = 0; i < 64; i += 1) {
139
+ const s1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
140
+ const t1 = (hh + s1 + ((e & f) ^ (~e & g)) + K[i] + w[i]) | 0;
141
+ const s0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
142
+ const t2 = (s0 + ((a & b) ^ (a & c) ^ (b & c))) | 0;
143
+ hh = g;
144
+ g = f;
145
+ f = e;
146
+ e = (d + t1) | 0;
147
+ d = c;
148
+ c = b;
149
+ b = a;
150
+ a = (t1 + t2) | 0;
151
+ }
152
+ h[0] = (h[0] + a) | 0;
153
+ h[1] = (h[1] + b) | 0;
154
+ h[2] = (h[2] + c) | 0;
155
+ h[3] = (h[3] + d) | 0;
156
+ h[4] = (h[4] + e) | 0;
157
+ h[5] = (h[5] + f) | 0;
158
+ h[6] = (h[6] + g) | 0;
159
+ h[7] = (h[7] + hh) | 0;
160
+ }
161
+ }
@@ -0,0 +1,33 @@
1
+ // Bounded state scans: a namespace larger than its bound fails loudly instead of returning a truncated view.
2
+ import { boundExceeded } from "./errors.mjs";
3
+
4
+ /** Every row of `namespace` (values only, in row-id order); fails INTERNAL_ERROR past `bound` rows. */
5
+ export function scanAll(context, namespace, bound) {
6
+ const out = [];
7
+ let after;
8
+ for (;;) {
9
+ const batch = context.state.scan(namespace, after === undefined ? { limit: 1000 } : { afterRowId: after, limit: 1000 });
10
+ for (const record of batch) {
11
+ out.push(record.value);
12
+ if (out.length > bound) boundExceeded(context, namespace, bound);
13
+ }
14
+ if (batch.length < 1000) return out;
15
+ after = batch[batch.length - 1].rowId;
16
+ }
17
+ }
18
+
19
+ /** Rows of a namespace whose row ids start with `prefix` (children keyed `<parent>:<child>`), bounded. */
20
+ export function scanPrefix(context, namespace, prefix, bound) {
21
+ const out = [];
22
+ let after = prefix;
23
+ for (;;) {
24
+ const batch = context.state.scan(namespace, { afterRowId: after, limit: 1000 });
25
+ for (const record of batch) {
26
+ if (!record.rowId.startsWith(prefix)) return out;
27
+ out.push(record.value);
28
+ if (out.length > bound) boundExceeded(context, namespace, bound);
29
+ }
30
+ if (batch.length < 1000) return out;
31
+ after = batch[batch.length - 1].rowId;
32
+ }
33
+ }
@@ -0,0 +1,149 @@
1
+ // Small deterministic helpers shared by every module. No Node built-ins, no wall clock, no randomness.
2
+
3
+ /** Clips caller text quoted in messages and outputs. */
4
+ export function clip(value, max = 200) {
5
+ const text = typeof value === "string" ? value : String(value);
6
+ return text.length <= max ? text : `${text.slice(0, max)}…`;
7
+ }
8
+
9
+ export const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
10
+ export const isInt = (value) => typeof value === "number" && Number.isInteger(value);
11
+ export const isNonEmptyString = (value, max = Infinity) => typeof value === "string" && value.length > 0 && value.length <= max;
12
+
13
+ /** UTF-8 byte length computed from code points (never Buffer). */
14
+ export function utf8Length(text) {
15
+ let bytes = 0;
16
+ for (let i = 0; i < text.length; i += 1) {
17
+ const code = text.charCodeAt(i);
18
+ if (code < 0x80) bytes += 1;
19
+ else if (code < 0x800) bytes += 2;
20
+ else if (code >= 0xd800 && code <= 0xdbff) {
21
+ bytes += 4;
22
+ i += 1;
23
+ } else bytes += 3;
24
+ }
25
+ return bytes;
26
+ }
27
+
28
+ /** UTF-8 bytes of a string as a plain array of 0..255 numbers. Lone surrogates become U+FFFD. */
29
+ export function utf8Bytes(text) {
30
+ const out = [];
31
+ for (let i = 0; i < text.length; i += 1) {
32
+ let code = text.charCodeAt(i);
33
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
34
+ const low = text.charCodeAt(i + 1);
35
+ if (low >= 0xdc00 && low <= 0xdfff) {
36
+ code = 0x10000 + ((code - 0xd800) << 10) + (low - 0xdc00);
37
+ i += 1;
38
+ } else code = 0xfffd;
39
+ } else if (code >= 0xd800 && code <= 0xdfff) code = 0xfffd;
40
+ if (code < 0x80) out.push(code);
41
+ else if (code < 0x800) out.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
42
+ else if (code < 0x10000) out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
43
+ else out.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /** Strict UTF-8 decoder over a byte array; returns null on any invalid sequence. */
49
+ export function utf8Decode(bytes) {
50
+ let out = "";
51
+ for (let i = 0; i < bytes.length; ) {
52
+ const b0 = bytes[i];
53
+ let code;
54
+ let need;
55
+ if (b0 < 0x80) {
56
+ code = b0;
57
+ need = 0;
58
+ } else if (b0 >= 0xc2 && b0 <= 0xdf) {
59
+ code = b0 & 0x1f;
60
+ need = 1;
61
+ } else if (b0 >= 0xe0 && b0 <= 0xef) {
62
+ code = b0 & 0x0f;
63
+ need = 2;
64
+ } else if (b0 >= 0xf0 && b0 <= 0xf4) {
65
+ code = b0 & 0x07;
66
+ need = 3;
67
+ } else return null;
68
+ for (let k = 1; k <= need; k += 1) {
69
+ const b = bytes[i + k];
70
+ if (b === undefined || (b & 0xc0) !== 0x80) return null;
71
+ code = (code << 6) | (b & 0x3f);
72
+ }
73
+ if (need === 2 && code < 0x800) return null;
74
+ if (need === 3 && (code < 0x10000 || code > 0x10ffff)) return null;
75
+ if (code >= 0xd800 && code <= 0xdfff) return null;
76
+ out += String.fromCodePoint(code);
77
+ i += need + 1;
78
+ }
79
+ return out;
80
+ }
81
+
82
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
83
+
84
+ export function base64Encode(bytes) {
85
+ let out = "";
86
+ for (let i = 0; i < bytes.length; i += 3) {
87
+ const a = bytes[i];
88
+ const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
89
+ const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
90
+ out += B64[a >> 2] + B64[((a & 3) << 4) | (b >> 4)];
91
+ out += i + 1 < bytes.length ? B64[((b & 15) << 2) | (c >> 6)] : "=";
92
+ out += i + 2 < bytes.length ? B64[c & 63] : "=";
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /** Strict base64 decode (standard alphabet, correct padding, no whitespace); returns null when malformed. */
98
+ export function base64Decode(text) {
99
+ if (typeof text !== "string" || text.length % 4 !== 0) return null;
100
+ const out = [];
101
+ for (let i = 0; i < text.length; i += 4) {
102
+ const chunk = text.slice(i, i + 4);
103
+ const pad = chunk.endsWith("==") ? 2 : chunk.endsWith("=") ? 1 : 0;
104
+ if (pad > 0 && i + 4 !== text.length) return null;
105
+ const values = [];
106
+ for (let k = 0; k < 4 - pad; k += 1) {
107
+ const idx = B64.indexOf(chunk[k]);
108
+ if (idx < 0) return null;
109
+ values.push(idx);
110
+ }
111
+ while (values.length < 4) values.push(0);
112
+ const n = (values[0] << 18) | (values[1] << 12) | (values[2] << 6) | values[3];
113
+ out.push((n >> 16) & 255);
114
+ if (pad < 2) out.push((n >> 8) & 255);
115
+ if (pad < 1) out.push(n & 255);
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /** Virtual microseconds -> ISO 8601 UTC string with milliseconds. */
121
+ export function isoFromUs(us) {
122
+ return new Date(Math.floor(us / 1000)).toISOString();
123
+ }
124
+
125
+ /** ISO 8601 UTC string (must end with Z or an explicit offset) -> microseconds, or null. */
126
+ export function usFromIso(text) {
127
+ if (typeof text !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?(Z|[+-]\d{2}:\d{2})$/.test(text)) return null;
128
+ const ms = Date.parse(text);
129
+ return Number.isFinite(ms) ? ms * 1000 : null;
130
+ }
131
+
132
+ /** ISO 8601 duration for a non-negative number of whole seconds (PT1M13S). */
133
+ export function isoDuration(seconds) {
134
+ const s = Math.max(0, Math.floor(seconds));
135
+ const h = Math.floor(s / 3600);
136
+ const m = Math.floor((s % 3600) / 60);
137
+ const r = s % 60;
138
+ let out = "PT";
139
+ if (h > 0) out += `${h}H`;
140
+ if (m > 0) out += `${m}M`;
141
+ if (r > 0 || out === "PT") out += `${r}S`;
142
+ return out;
143
+ }
144
+
145
+ export const ordered = (object, keys) => {
146
+ const out = {};
147
+ for (const key of keys) if (Object.hasOwn(object, key)) out[key] = object[key];
148
+ return out;
149
+ };
@@ -0,0 +1,115 @@
1
+ // Pydantic-flavoured parsers for caller values that may arrive as strings (multipart, query) or typed JSON.
2
+ import { clip } from "./util.mjs";
3
+
4
+ const TRUE = new Set(["true", "1", "yes", "on", "t", "y"]);
5
+ const FALSE = new Set(["false", "0", "no", "off", "f", "n"]);
6
+
7
+ export function parseBool(value, loc, issues, fallback) {
8
+ if (value === undefined || value === null || value === "") return fallback;
9
+ if (typeof value === "boolean") return value;
10
+ if (typeof value === "string") {
11
+ const lower = value.trim().toLowerCase();
12
+ if (TRUE.has(lower)) return true;
13
+ if (FALSE.has(lower)) return false;
14
+ }
15
+ if (value === 1) return true;
16
+ if (value === 0) return false;
17
+ issues.add(loc, "Input should be a valid boolean, unable to interpret input");
18
+ return fallback;
19
+ }
20
+
21
+ export function parseInteger(value, loc, issues, fallback, { min = -Infinity, max = Infinity } = {}) {
22
+ if (value === undefined || value === null || value === "") return fallback;
23
+ let number = value;
24
+ if (typeof value === "string") {
25
+ if (!/^\s*-?\d{1,15}\s*$/.test(value)) {
26
+ issues.add(loc, "Input should be a valid integer, unable to parse string as an integer");
27
+ return fallback;
28
+ }
29
+ number = Number(value.trim());
30
+ }
31
+ if (typeof number !== "number" || !Number.isInteger(number)) {
32
+ issues.add(loc, "Input should be a valid integer");
33
+ return fallback;
34
+ }
35
+ if (number < min) {
36
+ issues.add(loc, `Input should be greater than or equal to ${min}`);
37
+ return fallback;
38
+ }
39
+ if (number > max) {
40
+ issues.add(loc, `Input should be less than or equal to ${max}`);
41
+ return fallback;
42
+ }
43
+ return number;
44
+ }
45
+
46
+ const quoteList = (allowed) => {
47
+ const quoted = allowed.map((item) => `'${item}'`);
48
+ return quoted.length <= 1 ? quoted.join("") : `${quoted.slice(0, -1).join(", ")} or ${quoted[quoted.length - 1]}`;
49
+ };
50
+
51
+ export function parseEnum(value, loc, issues, allowed, fallback) {
52
+ if (value === undefined || value === null || value === "") return fallback;
53
+ if (typeof value === "string" && allowed.includes(value)) return value;
54
+ issues.add(loc, `Input should be ${quoteList(allowed)}`);
55
+ return fallback;
56
+ }
57
+
58
+ export function parseString(value, loc, issues, { required = false, min = 0, max = 200 } = {}) {
59
+ if (value === undefined || value === null) {
60
+ if (required) issues.add(loc, "Field required");
61
+ return null;
62
+ }
63
+ if (typeof value !== "string") {
64
+ issues.add(loc, "Input should be a valid string");
65
+ return null;
66
+ }
67
+ if (value.length < min) {
68
+ issues.add(loc, `String should have at least ${min} character${min === 1 ? "" : "s"}`);
69
+ return null;
70
+ }
71
+ if (value.length > max) {
72
+ issues.add(loc, `String should have at most ${max} characters`);
73
+ return null;
74
+ }
75
+ return value;
76
+ }
77
+
78
+ /** Strings must not carry U+FFFD (a mangled percent-encoding) when used as identifiers or filters. */
79
+ export const mangled = (value) => typeof value === "string" && value.includes("�");
80
+
81
+ export const describe = (value) => clip(typeof value === "string" ? value : JSON.stringify(value) ?? String(value), 100);
82
+
83
+ export const MAX_LANGUAGES = 20;
84
+ export const MAX_LANGUAGE_LENGTH = 20;
85
+
86
+ /**
87
+ * The `languages` parameter: an array of strings or one comma-separated string. More than MAX_LANGUAGES entries, an
88
+ * entry longer than MAX_LANGUAGE_LENGTH or a non-string entry is a validation issue; the list is never shortened.
89
+ * Returns the list (empty when absent) or null (issue added).
90
+ */
91
+ export function parseLanguages(value, loc, issues) {
92
+ if (value === undefined || value === null || value === "") return [];
93
+ let list;
94
+ if (Array.isArray(value)) list = value;
95
+ else if (typeof value === "string") list = value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
96
+ else {
97
+ issues.add(loc, "Input should be a valid list");
98
+ return null;
99
+ }
100
+ if (list.length > MAX_LANGUAGES) {
101
+ issues.add(loc, `List should have at most ${MAX_LANGUAGES} items after validation, not ${list.length}`);
102
+ return null;
103
+ }
104
+ let ok = true;
105
+ for (const [index, item] of list.entries()) {
106
+ if (typeof item !== "string") {
107
+ issues.add(`${loc}.${index}`, "Input should be a valid string");
108
+ ok = false;
109
+ } else if (item.length > MAX_LANGUAGE_LENGTH) {
110
+ issues.add(`${loc}.${index}`, `String should have at most ${MAX_LANGUAGE_LENGTH} characters`);
111
+ ok = false;
112
+ }
113
+ }
114
+ return ok ? list : null;
115
+ }
@@ -0,0 +1,78 @@
1
+ // Decoders for the two multipart routes (`POST /general/v0/general`, `POST /api/v1/workflows/{id}/run`).
2
+ // The framework hands `text` bodies as strict UTF-8 strings, so only text documents can arrive here.
3
+ import { isMultipart, parseMultipart } from "./multipart.mjs";
4
+ import { base64Decode, clip, utf8Decode } from "./util.mjs";
5
+ import { RESERVED, headerOf } from "./wire.mjs";
6
+
7
+ const MAX_FILES = 32;
8
+ const LIST_FIELDS = new Set(["languages", "ocr_languages", "skip_infer_table_types", "extract_image_block_types"]);
9
+ const MAX_FIELDS = 64;
10
+
11
+ function fileOf(part) {
12
+ const file = {
13
+ filename: typeof part.filename === "string" ? clip(part.filename, 500) : "",
14
+ content_type: part.contentType !== null && part.contentType !== "application/octet-stream" ? clip(part.contentType, 200) : null,
15
+ content: part.body,
16
+ };
17
+ if (part.lastModified !== null) file.last_modified = clip(part.lastModified, 100);
18
+ if (part.transferEncoding === "base64") {
19
+ const bytes = base64Decode(part.body.replace(/[\r\n]/g, ""));
20
+ const text = bytes === null ? null : utf8Decode(bytes);
21
+ if (text === null) file.bad_transfer = true;
22
+ else file.content = text;
23
+ } else if (part.transferEncoding !== null && part.transferEncoding !== "binary" && part.transferEncoding !== "8bit" && part.transferEncoding !== "7bit") {
24
+ file.bad_transfer = true;
25
+ }
26
+ return file;
27
+ }
28
+
29
+ /** Folds multipart parts into arguments: `fileField` parts -> array of files, other parts -> text or string-array fields. */
30
+ function foldParts(parts, fileField, refuse) {
31
+ const args = {};
32
+ const files = [];
33
+ let fieldCount = 0;
34
+ for (const part of parts) {
35
+ if (part.name === fileField || part.name === `${fileField}[]`) {
36
+ if (files.length >= MAX_FILES) return refuse(`422:loc=body.${fileField}: At most ${MAX_FILES} files per request`);
37
+ files.push(fileOf(part));
38
+ continue;
39
+ }
40
+ const name = part.name.endsWith("[]") ? part.name.slice(0, -2) : part.name;
41
+ if (name.length === 0 || name === RESERVED || name === "__proto__" || name === "constructor" || name === "prototype") return refuse(`422:loc=body: Invalid form field name "${clip(part.name, 60)}"`);
42
+ if (part.filename !== null) return refuse(`422:loc=body.${clip(name, 100)}: Unexpected file upload; only "${fileField}" parts may carry a file`);
43
+ fieldCount += 1;
44
+ if (fieldCount > MAX_FIELDS) return refuse("422:loc=body: Too many form fields");
45
+ if (LIST_FIELDS.has(name)) {
46
+ if (!Object.hasOwn(args, name)) args[name] = [];
47
+ if (args[name].length < 64) args[name].push(part.body);
48
+ } else if (!Object.hasOwn(args, name)) args[name] = part.body;
49
+ }
50
+ if (files.length > 0) args[fileField] = files;
51
+ return args;
52
+ }
53
+
54
+ function multipartArgs(request, fileField, allowEmpty) {
55
+ const refuse = (message) => ({ [RESERVED]: clip(message, 300) });
56
+ const contentType = headerOf(request, "content-type");
57
+ const body = request.body.kind === "text" ? request.body.value : request.body.kind === "form" ? "form" : "";
58
+ if (request.body.kind === "json") return refuse("422:loc=body: Expected multipart/form-data");
59
+ if (request.body.kind === "form") return refuse("422:loc=body: Expected multipart/form-data, not application/x-www-form-urlencoded");
60
+ if (body.length === 0 && allowEmpty && (contentType === null || !isMultipart(contentType))) return {};
61
+ if (!isMultipart(contentType)) return refuse("422:loc=body: Expected multipart/form-data");
62
+ const parsed = parseMultipart(contentType, body);
63
+ if (parsed.error !== undefined) return refuse(`400:${parsed.error}`);
64
+ return foldParts(parsed.parts, fileField, refuse);
65
+ }
66
+
67
+ export function decodePartition(request) {
68
+ return { arguments: multipartArgs(request, "files", false) };
69
+ }
70
+
71
+ export function decodeRun(request) {
72
+ const args = { workflow_id: typeof request.path.workflow_id === "string" ? request.path.workflow_id : "" };
73
+ Object.assign(args, multipartArgs(request, "input_files", true));
74
+ const out = { arguments: args };
75
+ const key = headerOf(request, "idempotency-key");
76
+ if (typeof key === "string" && key.length > 0 && key.length <= 255) out.idempotencyKey = key;
77
+ return out;
78
+ }