@struktur/sdk 0.1.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 (96) hide show
  1. package/README.md +79 -0
  2. package/package.json +33 -0
  3. package/src/artifacts/AGENTS.md +16 -0
  4. package/src/artifacts/fileToArtifact.test.ts +37 -0
  5. package/src/artifacts/fileToArtifact.ts +44 -0
  6. package/src/artifacts/input.test.ts +243 -0
  7. package/src/artifacts/input.ts +360 -0
  8. package/src/artifacts/providers.test.ts +19 -0
  9. package/src/artifacts/providers.ts +7 -0
  10. package/src/artifacts/urlToArtifact.test.ts +23 -0
  11. package/src/artifacts/urlToArtifact.ts +19 -0
  12. package/src/auth/AGENTS.md +11 -0
  13. package/src/auth/config.test.ts +132 -0
  14. package/src/auth/config.ts +129 -0
  15. package/src/auth/tokens.test.ts +58 -0
  16. package/src/auth/tokens.ts +229 -0
  17. package/src/chunking/AGENTS.md +11 -0
  18. package/src/chunking/ArtifactBatcher.test.ts +22 -0
  19. package/src/chunking/ArtifactBatcher.ts +110 -0
  20. package/src/chunking/ArtifactSplitter.test.ts +38 -0
  21. package/src/chunking/ArtifactSplitter.ts +151 -0
  22. package/src/debug/AGENTS.md +79 -0
  23. package/src/debug/logger.test.ts +244 -0
  24. package/src/debug/logger.ts +211 -0
  25. package/src/extract.test.ts +22 -0
  26. package/src/extract.ts +114 -0
  27. package/src/fields.test.ts +663 -0
  28. package/src/fields.ts +239 -0
  29. package/src/index.test.ts +20 -0
  30. package/src/index.ts +93 -0
  31. package/src/llm/AGENTS.md +9 -0
  32. package/src/llm/LLMClient.test.ts +196 -0
  33. package/src/llm/LLMClient.ts +106 -0
  34. package/src/llm/RetryingRunner.test.ts +174 -0
  35. package/src/llm/RetryingRunner.ts +188 -0
  36. package/src/llm/message.test.ts +42 -0
  37. package/src/llm/message.ts +47 -0
  38. package/src/llm/models.test.ts +82 -0
  39. package/src/llm/models.ts +190 -0
  40. package/src/merge/AGENTS.md +6 -0
  41. package/src/merge/Deduplicator.test.ts +108 -0
  42. package/src/merge/Deduplicator.ts +45 -0
  43. package/src/merge/SmartDataMerger.test.ts +177 -0
  44. package/src/merge/SmartDataMerger.ts +56 -0
  45. package/src/parsers/AGENTS.md +58 -0
  46. package/src/parsers/collect.test.ts +56 -0
  47. package/src/parsers/collect.ts +31 -0
  48. package/src/parsers/index.ts +6 -0
  49. package/src/parsers/mime.test.ts +91 -0
  50. package/src/parsers/mime.ts +137 -0
  51. package/src/parsers/npm.ts +26 -0
  52. package/src/parsers/pdf.test.ts +394 -0
  53. package/src/parsers/pdf.ts +194 -0
  54. package/src/parsers/runner.test.ts +95 -0
  55. package/src/parsers/runner.ts +177 -0
  56. package/src/parsers/types.ts +29 -0
  57. package/src/prompts/AGENTS.md +8 -0
  58. package/src/prompts/DeduplicationPrompt.test.ts +41 -0
  59. package/src/prompts/DeduplicationPrompt.ts +37 -0
  60. package/src/prompts/ExtractorPrompt.test.ts +21 -0
  61. package/src/prompts/ExtractorPrompt.ts +72 -0
  62. package/src/prompts/ParallelMergerPrompt.test.ts +8 -0
  63. package/src/prompts/ParallelMergerPrompt.ts +37 -0
  64. package/src/prompts/SequentialExtractorPrompt.test.ts +24 -0
  65. package/src/prompts/SequentialExtractorPrompt.ts +82 -0
  66. package/src/prompts/formatArtifacts.test.ts +39 -0
  67. package/src/prompts/formatArtifacts.ts +46 -0
  68. package/src/strategies/AGENTS.md +6 -0
  69. package/src/strategies/DoublePassAutoMergeStrategy.test.ts +53 -0
  70. package/src/strategies/DoublePassAutoMergeStrategy.ts +270 -0
  71. package/src/strategies/DoublePassStrategy.test.ts +48 -0
  72. package/src/strategies/DoublePassStrategy.ts +179 -0
  73. package/src/strategies/ParallelAutoMergeStrategy.test.ts +152 -0
  74. package/src/strategies/ParallelAutoMergeStrategy.ts +241 -0
  75. package/src/strategies/ParallelStrategy.test.ts +61 -0
  76. package/src/strategies/ParallelStrategy.ts +157 -0
  77. package/src/strategies/SequentialAutoMergeStrategy.test.ts +66 -0
  78. package/src/strategies/SequentialAutoMergeStrategy.ts +222 -0
  79. package/src/strategies/SequentialStrategy.test.ts +53 -0
  80. package/src/strategies/SequentialStrategy.ts +119 -0
  81. package/src/strategies/SimpleStrategy.test.ts +46 -0
  82. package/src/strategies/SimpleStrategy.ts +74 -0
  83. package/src/strategies/concurrency.test.ts +16 -0
  84. package/src/strategies/concurrency.ts +14 -0
  85. package/src/strategies/index.test.ts +20 -0
  86. package/src/strategies/index.ts +7 -0
  87. package/src/strategies/utils.test.ts +76 -0
  88. package/src/strategies/utils.ts +56 -0
  89. package/src/tokenization.test.ts +119 -0
  90. package/src/tokenization.ts +71 -0
  91. package/src/types.test.ts +25 -0
  92. package/src/types.ts +116 -0
  93. package/src/validation/AGENTS.md +6 -0
  94. package/src/validation/validator.test.ts +172 -0
  95. package/src/validation/validator.ts +82 -0
  96. package/tsconfig.json +22 -0
@@ -0,0 +1,56 @@
1
+ import type { Artifact, ExtractionEvents, Usage } from "../types";
2
+ import type { DebugLogger } from "../debug/logger";
3
+ import { batchArtifacts, type BatchOptions } from "../chunking/ArtifactBatcher";
4
+ import { buildUserContent } from "../llm/message";
5
+ import { runWithRetries } from "../llm/RetryingRunner";
6
+
7
+ export const serializeSchema = (schema: unknown) => {
8
+ return JSON.stringify(schema);
9
+ };
10
+
11
+ export const mergeUsage = (usages: Usage[]) => {
12
+ return usages.reduce(
13
+ (acc, usage) => ({
14
+ inputTokens: acc.inputTokens + usage.inputTokens,
15
+ outputTokens: acc.outputTokens + usage.outputTokens,
16
+ totalTokens: acc.totalTokens + usage.totalTokens,
17
+ }),
18
+ { inputTokens: 0, outputTokens: 0, totalTokens: 0 }
19
+ );
20
+ };
21
+
22
+ export const getBatches = (
23
+ artifacts: Artifact[],
24
+ options: BatchOptions,
25
+ debug?: DebugLogger
26
+ ) => {
27
+ return batchArtifacts(artifacts, { ...options, debug });
28
+ };
29
+
30
+ export const extractWithPrompt = async <T>(options: {
31
+ model: unknown;
32
+ schema: unknown;
33
+ system: string;
34
+ user: string;
35
+ artifacts: Artifact[];
36
+ events?: ExtractionEvents;
37
+ execute?: typeof runWithRetries<T>;
38
+ strict?: boolean;
39
+ debug?: DebugLogger;
40
+ callId?: string;
41
+ }) => {
42
+ const userContent = buildUserContent(options.user, options.artifacts);
43
+ const result = await runWithRetries<T>({
44
+ model: options.model,
45
+ schema: options.schema,
46
+ system: options.system,
47
+ user: userContent,
48
+ events: options.events,
49
+ execute: options.execute,
50
+ strict: options.strict,
51
+ debug: options.debug,
52
+ callId: options.callId,
53
+ });
54
+
55
+ return result;
56
+ };
@@ -0,0 +1,119 @@
1
+ import { test, expect } from "bun:test";
2
+ import type { Artifact } from "./types";
3
+ import {
4
+ countArtifactTokens,
5
+ estimateTextTokens,
6
+ estimateImageTokens,
7
+ countContentTokens,
8
+ } from "./tokenization";
9
+
10
+ test("estimateTextTokens uses default ratio", () => {
11
+ expect(estimateTextTokens("abcd")).toBe(1);
12
+ expect(estimateTextTokens("abcdefgh")).toBe(2);
13
+ });
14
+
15
+ test("estimateTextTokens uses custom ratio", () => {
16
+ expect(estimateTextTokens("abcd", { textTokenRatio: 2 })).toBe(2);
17
+ expect(estimateTextTokens("abcdefgh", { textTokenRatio: 2 })).toBe(4);
18
+ });
19
+
20
+ test("estimateImageTokens uses default image tokens", () => {
21
+ expect(estimateImageTokens({ type: "image" })).toBe(1000);
22
+ });
23
+
24
+ test("estimateImageTokens uses custom image tokens", () => {
25
+ expect(estimateImageTokens({ type: "image" }, { defaultImageTokens: 500 })).toBe(500);
26
+ });
27
+
28
+ test("countArtifactTokens honors artifact.tokens override", () => {
29
+ const artifact: Artifact = {
30
+ id: "a1",
31
+ type: "text",
32
+ raw: async () => Buffer.from(""),
33
+ contents: [{ text: "hello" }],
34
+ tokens: 42,
35
+ };
36
+
37
+ expect(countArtifactTokens(artifact)).toBe(42);
38
+ });
39
+
40
+ test("countArtifactTokens sums text and media tokens", () => {
41
+ const artifact: Artifact = {
42
+ id: "a2",
43
+ type: "text",
44
+ raw: async () => Buffer.from(""),
45
+ contents: [
46
+ {
47
+ text: "abcdefgh",
48
+ media: [{ type: "image" }],
49
+ },
50
+ ],
51
+ };
52
+
53
+ const tokens = countArtifactTokens(artifact);
54
+ expect(tokens).toBe(1002);
55
+ });
56
+
57
+ test("countContentTokens counts text tokens", () => {
58
+ const tokens = countContentTokens({ text: "abcdefgh" });
59
+ expect(tokens).toBe(2);
60
+ });
61
+
62
+ test("countContentTokens counts image tokens", () => {
63
+ const tokens = countContentTokens({ media: [{ type: "image" }] });
64
+ expect(tokens).toBe(1000);
65
+ });
66
+
67
+ test("countContentTokens counts image text tokens", () => {
68
+ const tokens = countContentTokens({
69
+ media: [{ type: "image", text: "abcd" }],
70
+ });
71
+ expect(tokens).toBe(1001);
72
+ });
73
+
74
+ test("countContentTokens sums multiple images", () => {
75
+ const tokens = countContentTokens({
76
+ media: [{ type: "image" }, { type: "image" }],
77
+ });
78
+ expect(tokens).toBe(2000);
79
+ });
80
+
81
+ test("countContentTokens handles empty content", () => {
82
+ const tokens = countContentTokens({});
83
+ expect(tokens).toBe(0);
84
+ });
85
+
86
+ test("countArtifactTokens sums multiple contents", () => {
87
+ const artifact: Artifact = {
88
+ id: "a1",
89
+ type: "text",
90
+ raw: async () => Buffer.from(""),
91
+ contents: [
92
+ { text: "abcd" },
93
+ { text: "efgh" },
94
+ { media: [{ type: "image" }] },
95
+ ],
96
+ };
97
+
98
+ const tokens = countArtifactTokens(artifact);
99
+ expect(tokens).toBe(1002);
100
+ });
101
+
102
+ test("countArtifactTokens with custom options", () => {
103
+ const artifact: Artifact = {
104
+ id: "a1",
105
+ type: "text",
106
+ raw: async () => Buffer.from(""),
107
+ contents: [
108
+ { text: "abcdefgh" },
109
+ { media: [{ type: "image" }] },
110
+ ],
111
+ };
112
+
113
+ const tokens = countArtifactTokens(artifact, {
114
+ textTokenRatio: 2,
115
+ defaultImageTokens: 500,
116
+ });
117
+
118
+ expect(tokens).toBe(504);
119
+ });
@@ -0,0 +1,71 @@
1
+ import type { Artifact, ArtifactContent, ArtifactImage } from "./types";
2
+
3
+ export type TokenCountOptions = {
4
+ textTokenRatio?: number;
5
+ defaultImageTokens?: number;
6
+ };
7
+
8
+ const defaultOptions: Required<TokenCountOptions> = {
9
+ textTokenRatio: 4,
10
+ defaultImageTokens: 1000,
11
+ };
12
+
13
+ const mergeOptions = (options?: TokenCountOptions) => ({
14
+ ...defaultOptions,
15
+ ...(options ?? {}),
16
+ });
17
+
18
+ export const estimateTextTokens = (text: string, options?: TokenCountOptions) => {
19
+ const { textTokenRatio } = mergeOptions(options);
20
+ return Math.ceil(text.length / textTokenRatio);
21
+ };
22
+
23
+ export const estimateImageTokens = (
24
+ _image: ArtifactImage,
25
+ options?: TokenCountOptions
26
+ ) => {
27
+ const { defaultImageTokens } = mergeOptions(options);
28
+ return defaultImageTokens;
29
+ };
30
+
31
+ export const countContentTokens = (
32
+ content: ArtifactContent,
33
+ options?: TokenCountOptions
34
+ ) => {
35
+ let tokens = 0;
36
+
37
+ if (content.text) {
38
+ tokens += estimateTextTokens(content.text, options);
39
+ }
40
+
41
+ if (content.media?.length) {
42
+ for (const media of content.media) {
43
+ tokens += estimateImageTokens(media, options);
44
+ if (media.text) {
45
+ tokens += estimateTextTokens(media.text, options);
46
+ }
47
+ }
48
+ }
49
+
50
+ return tokens;
51
+ };
52
+
53
+ export const countArtifactTokens = (
54
+ artifact: Artifact,
55
+ options?: TokenCountOptions
56
+ ) => {
57
+ if (typeof artifact.tokens === "number") {
58
+ return artifact.tokens;
59
+ }
60
+
61
+ return artifact.contents.reduce(
62
+ (total, content) => total + countContentTokens(content, options),
63
+ 0
64
+ );
65
+ };
66
+
67
+ export const countArtifactImages = (artifact: Artifact) => {
68
+ return artifact.contents.reduce((count, content) => {
69
+ return count + (content.media?.length ?? 0);
70
+ }, 0);
71
+ };
@@ -0,0 +1,25 @@
1
+ import { test, expect } from "bun:test";
2
+ import type { Artifact, ExtractionResult, ExtractionStrategy, Usage } from "./types";
3
+
4
+ test("types can be used to build core DTOs", async () => {
5
+ const usage: Usage = { inputTokens: 1, outputTokens: 2, totalTokens: 3 };
6
+ const artifact: Artifact = {
7
+ id: "a1",
8
+ type: "text",
9
+ raw: async () => Buffer.from(""),
10
+ contents: [{ text: "hello" }],
11
+ };
12
+
13
+ const strategy: ExtractionStrategy<{ title: string }> = {
14
+ name: "test",
15
+ run: async () => ({ data: { title: "ok" }, usage }),
16
+ };
17
+
18
+ const result: ExtractionResult<{ title: string }> = await strategy.run({
19
+ artifacts: [artifact],
20
+ schema: { type: "object" },
21
+ strategy,
22
+ });
23
+
24
+ expect(result.data.title).toBe("ok");
25
+ });
package/src/types.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { JSONSchemaType } from "ajv";
2
+ import type { DebugLogger } from "./debug/logger";
3
+
4
+ export type ArtifactType = "text" | "image" | "pdf" | "file";
5
+
6
+ export type ImageType = "embedded" | "screenshot";
7
+
8
+ export type ArtifactImage = {
9
+ type: "image";
10
+ url?: string;
11
+ base64?: string;
12
+ contents?: Buffer;
13
+ text?: string;
14
+ x?: number;
15
+ y?: number;
16
+ width?: number;
17
+ height?: number;
18
+ imageType?: ImageType;
19
+ };
20
+
21
+ export type ArtifactContent = {
22
+ page?: number;
23
+ text?: string;
24
+ media?: ArtifactImage[];
25
+ };
26
+
27
+ export interface Artifact {
28
+ id: string;
29
+ type: ArtifactType;
30
+ raw: () => Promise<Buffer>;
31
+ contents: ArtifactContent[];
32
+ metadata?: Record<string, unknown>;
33
+ tokens?: number;
34
+ }
35
+
36
+ export type Usage = {
37
+ inputTokens: number;
38
+ outputTokens: number;
39
+ totalTokens: number;
40
+ };
41
+
42
+ export type ExtractionResult<T> = {
43
+ data: T;
44
+ usage: Usage;
45
+ error?: Error;
46
+ };
47
+
48
+ export type StepInfo = {
49
+ step: number;
50
+ total?: number;
51
+ label?: string;
52
+ };
53
+
54
+ export type ProgressInfo = {
55
+ current: number;
56
+ total: number;
57
+ percent?: number;
58
+ };
59
+
60
+ export type MessageInfo = {
61
+ role: "system" | "user" | "assistant" | "tool";
62
+ content: unknown;
63
+ };
64
+
65
+ export type TokenUsageInfo = Usage & {
66
+ model?: string;
67
+ };
68
+
69
+ export type RetryInfo = {
70
+ attempt: number;
71
+ maxAttempts: number;
72
+ reason?: string;
73
+ };
74
+
75
+ export type ExtractionEvents = {
76
+ onStep?: (info: StepInfo) => void | Promise<void>;
77
+ onMessage?: (info: MessageInfo) => void | Promise<void>;
78
+ onProgress?: (info: ProgressInfo) => void | Promise<void>;
79
+ onTokenUsage?: (info: TokenUsageInfo) => void | Promise<void>;
80
+ onRetry?: (info: RetryInfo) => void | Promise<void>;
81
+ };
82
+
83
+ export type AnyJSONSchema = Record<string, unknown>;
84
+ export type TypedJSONSchema<T> = JSONSchemaType<T>;
85
+
86
+ export type ProviderModelsResult = {
87
+ provider: string;
88
+ ok: boolean;
89
+ models?: string[];
90
+ error?: string;
91
+ };
92
+
93
+ export type ExtractionOptions<T> = {
94
+ artifacts: Artifact[];
95
+ /**
96
+ * JSON Schema for the extracted output.
97
+ * Exactly one of `schema`, `fields`, or an inline schema via the CLI must be provided.
98
+ */
99
+ schema?: TypedJSONSchema<T> | AnyJSONSchema;
100
+ /**
101
+ * Shorthand schema definition as a comma-separated string of `name` or `name:type` tokens.
102
+ * E.g. `"title, price:number"`. Defaults to `string` when no type is specified.
103
+ * Mutually exclusive with `schema`.
104
+ */
105
+ fields?: string;
106
+ strategy: ExtractionStrategy<T>;
107
+ events?: ExtractionEvents;
108
+ debug?: DebugLogger;
109
+ strict?: boolean;
110
+ }
111
+
112
+ export interface ExtractionStrategy<T> {
113
+ name: string;
114
+ run(options: ExtractionOptions<T>): Promise<ExtractionResult<T>>;
115
+ getEstimatedSteps?: (artifacts: Artifact[]) => number;
116
+ }
@@ -0,0 +1,6 @@
1
+ Validation module
2
+
3
+ - Purpose: Ajv schema validation and error shaping.
4
+ - Key files: `validator.ts`.
5
+ - Design: `validateOrThrow` compiles schemas and throws `SchemaValidationError` on failure; `createAjv` registers `ajv-formats` for common schema formats.
6
+ - Tests: `validator.test.ts`.
@@ -0,0 +1,172 @@
1
+ import { test, expect } from "bun:test";
2
+ import type { JSONSchemaType, ErrorObject } from "ajv";
3
+ import { createAjv, validateOrThrow, SchemaValidationError, isRequiredError, validateAllowingMissingRequired } from "./validator";
4
+
5
+ type Person = {
6
+ name: string;
7
+ age: number;
8
+ };
9
+
10
+ const personSchema: JSONSchemaType<Person> = {
11
+ type: "object",
12
+ properties: {
13
+ name: { type: "string" },
14
+ age: { type: "number" },
15
+ },
16
+ required: ["name", "age"],
17
+ additionalProperties: false,
18
+ };
19
+
20
+ type NestedSchema = {
21
+ user: {
22
+ name: string;
23
+ email: string;
24
+ };
25
+ items: string[];
26
+ };
27
+
28
+ const nestedSchema: JSONSchemaType<NestedSchema> = {
29
+ type: "object",
30
+ properties: {
31
+ user: {
32
+ type: "object",
33
+ properties: {
34
+ name: { type: "string" },
35
+ email: { type: "string", format: "email" },
36
+ },
37
+ required: ["name", "email"],
38
+ },
39
+ items: {
40
+ type: "array",
41
+ items: { type: "string" },
42
+ },
43
+ },
44
+ required: ["user", "items"],
45
+ };
46
+
47
+ test("validateOrThrow returns typed data for valid input", () => {
48
+ const ajv = createAjv();
49
+ const data = validateOrThrow<Person>(ajv, personSchema, {
50
+ name: "Ada",
51
+ age: 33,
52
+ });
53
+
54
+ expect(data.name).toBe("Ada");
55
+ expect(data.age).toBe(33);
56
+ });
57
+
58
+ test("validateOrThrow throws SchemaValidationError for invalid input", () => {
59
+ const ajv = createAjv();
60
+ try {
61
+ validateOrThrow<Person>(ajv, personSchema, { name: "Ada" });
62
+ throw new Error("Expected validation error");
63
+ } catch (error) {
64
+ expect(error).toBeInstanceOf(SchemaValidationError);
65
+ const validationError = error as SchemaValidationError;
66
+ expect(validationError.errors.length).toBeGreaterThan(0);
67
+ }
68
+ });
69
+
70
+ test("createAjv supports common formats", () => {
71
+ const ajv = createAjv();
72
+ const schema: JSONSchemaType<string> = { type: "string", format: "email" };
73
+
74
+ const data = validateOrThrow<string>(ajv, schema, "test@example.com");
75
+ expect(data).toBe("test@example.com");
76
+
77
+ try {
78
+ validateOrThrow<string>(ajv, schema, "not-an-email");
79
+ throw new Error("Expected validation error");
80
+ } catch (error) {
81
+ expect(error).toBeInstanceOf(SchemaValidationError);
82
+ }
83
+ });
84
+
85
+ test("isRequiredError identifies required constraint violations", () => {
86
+ const requiredError = {
87
+ keyword: "required",
88
+ params: { missingProperty: "name" },
89
+ instancePath: "",
90
+ schemaPath: "#/required"
91
+ } as unknown as ErrorObject;
92
+ const typeError = {
93
+ keyword: "type",
94
+ params: { type: "string" },
95
+ instancePath: "/age",
96
+ schemaPath: "#/properties/age/type"
97
+ } as unknown as ErrorObject;
98
+
99
+ expect(isRequiredError(requiredError)).toBe(true);
100
+ expect(isRequiredError(typeError)).toBe(false);
101
+ });
102
+
103
+ test("validateAllowingMissingRequired ignores required errors but catches type errors", () => {
104
+ const ajv = createAjv();
105
+
106
+ const resultMissingRequired = validateAllowingMissingRequired<Person>(
107
+ ajv,
108
+ personSchema,
109
+ { name: "Ada" }
110
+ );
111
+ expect(resultMissingRequired.valid).toBe(true);
112
+ if (resultMissingRequired.valid) {
113
+ expect(resultMissingRequired.data.name).toBe("Ada");
114
+ }
115
+
116
+ const resultTypeError = validateAllowingMissingRequired<Person>(
117
+ ajv,
118
+ personSchema,
119
+ { name: "Ada", age: "thirty-three" }
120
+ );
121
+ expect(resultTypeError.valid).toBe(false);
122
+ if (!resultTypeError.valid) {
123
+ expect(resultTypeError.errors.some(e => e.keyword === "type")).toBe(true);
124
+ }
125
+
126
+ const resultValid = validateAllowingMissingRequired<Person>(
127
+ ajv,
128
+ personSchema,
129
+ { name: "Ada", age: 33 }
130
+ );
131
+ expect(resultValid.valid).toBe(true);
132
+ if (resultValid.valid) {
133
+ expect(resultValid.data.name).toBe("Ada");
134
+ expect(resultValid.data.age).toBe(33);
135
+ }
136
+ });
137
+
138
+ test("validateAllowingMissingRequired handles format errors", () => {
139
+ const ajv = createAjv();
140
+
141
+ const result = validateAllowingMissingRequired<NestedSchema>(
142
+ ajv,
143
+ nestedSchema,
144
+ {
145
+ user: { name: "Ada", email: "not-an-email" },
146
+ items: ["a", "b"]
147
+ }
148
+ );
149
+
150
+ expect(result.valid).toBe(false);
151
+ if (!result.valid) {
152
+ expect(result.errors.some(e => e.keyword === "format")).toBe(true);
153
+ }
154
+ });
155
+
156
+ test("validateAllowingMissingRequired handles nested required field errors", () => {
157
+ const ajv = createAjv();
158
+
159
+ const result = validateAllowingMissingRequired<NestedSchema>(
160
+ ajv,
161
+ nestedSchema,
162
+ {
163
+ user: { name: "Ada" },
164
+ items: ["a"]
165
+ }
166
+ );
167
+
168
+ expect(result.valid).toBe(true);
169
+ if (result.valid) {
170
+ expect(result.data.user.name).toBe("Ada");
171
+ }
172
+ });
@@ -0,0 +1,82 @@
1
+ import Ajv, { type AnySchema, type ErrorObject, type JSONSchemaType } from "ajv";
2
+ import addFormats from "ajv-formats";
3
+
4
+ export type ValidationErrors = ErrorObject[];
5
+
6
+ export type ValidationMode = 'strict' | 'lenient';
7
+
8
+ export type ValidationResult<T> =
9
+ | { valid: true; data: T }
10
+ | { valid: false; errors: ErrorObject[] };
11
+
12
+ export class SchemaValidationError extends Error {
13
+ public readonly errors: ValidationErrors;
14
+
15
+ constructor(message: string, errors: ValidationErrors) {
16
+ super(message);
17
+ this.name = "SchemaValidationError";
18
+ this.errors = errors;
19
+ }
20
+ }
21
+
22
+ export const createAjv = () => {
23
+ const ajv = new Ajv({
24
+ allErrors: true,
25
+ strict: false,
26
+ allowUnionTypes: true,
27
+ });
28
+ addFormats(ajv);
29
+ return ajv;
30
+ };
31
+
32
+ export type SchemaInput<T> = JSONSchemaType<T> | AnySchema;
33
+
34
+ export const validateOrThrow = <T>(
35
+ ajv: Ajv,
36
+ schema: SchemaInput<T>,
37
+ data: unknown
38
+ ): T => {
39
+ const validate = ajv.compile<T>(schema);
40
+ const valid = validate(data);
41
+
42
+ if (!valid) {
43
+ const errors = validate.errors ?? [];
44
+ const message = "Schema validation failed";
45
+ throw new SchemaValidationError(message, errors);
46
+ }
47
+
48
+ return data as T;
49
+ };
50
+
51
+ export const isRequiredError = (error: ErrorObject): boolean => {
52
+ return error.keyword === "required";
53
+ };
54
+
55
+ export const validateAllowingMissingRequired = <T>(
56
+ ajv: Ajv,
57
+ schema: SchemaInput<T>,
58
+ data: unknown,
59
+ isFinalAttempt: boolean = true
60
+ ): ValidationResult<T> => {
61
+ const validate = ajv.compile<T>(schema);
62
+ const valid = validate(data);
63
+
64
+ if (valid) {
65
+ return { valid: true, data: data as T };
66
+ }
67
+
68
+ const errors = validate.errors ?? [];
69
+ const nonRequiredErrors = errors.filter((error) => !isRequiredError(error));
70
+
71
+ if (nonRequiredErrors.length === 0) {
72
+ // Only required field errors
73
+ // On final attempt, accept partial data
74
+ // On non-final attempts, return invalid to trigger retry
75
+ if (isFinalAttempt) {
76
+ return { valid: true, data: data as T };
77
+ }
78
+ return { valid: false, errors };
79
+ }
80
+
81
+ return { valid: false, errors: nonRequiredErrors };
82
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "target": "ESNext",
5
+ "module": "Preserve",
6
+ "moduleDetection": "force",
7
+ "jsx": "react-jsx",
8
+ "allowJs": true,
9
+ "moduleResolution": "bundler",
10
+ "allowImportingTsExtensions": true,
11
+ "verbatimModuleSyntax": true,
12
+ "noEmit": true,
13
+ "strict": true,
14
+ "skipLibCheck": true,
15
+ "noFallthroughCasesInSwitch": true,
16
+ "noUncheckedIndexedAccess": true,
17
+ "noImplicitOverride": true,
18
+ "noUnusedLocals": false,
19
+ "noUnusedParameters": false,
20
+ "noPropertyAccessFromIndexSignature": false
21
+ }
22
+ }