@eventcatalog/core 3.25.5 → 3.26.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 (36) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/catalog-to-astro-content-directory.cjs +1 -1
  6. package/dist/{chunk-NIGGP5OH.js → chunk-7CRFNX47.js} +1 -1
  7. package/dist/{chunk-G3KLCHZ7.js → chunk-ASC3AR2X.js} +1 -1
  8. package/dist/{chunk-KMBHKZX5.js → chunk-FQNBDDUF.js} +1 -1
  9. package/dist/{chunk-TQQ3EOI3.js → chunk-GCNIIIFG.js} +1 -1
  10. package/dist/{chunk-6QMOE3KE.js → chunk-XUN32ZVJ.js} +1 -1
  11. package/dist/constants.cjs +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/eventcatalog.cjs +563 -20
  14. package/dist/eventcatalog.js +572 -27
  15. package/dist/generate.cjs +1 -1
  16. package/dist/generate.js +3 -3
  17. package/dist/utils/cli-logger.cjs +1 -1
  18. package/dist/utils/cli-logger.js +2 -2
  19. package/eventcatalog/astro.config.mjs +2 -1
  20. package/eventcatalog/integrations/eventcatalog-features.ts +13 -0
  21. package/eventcatalog/public/icons/graphql.svg +3 -1
  22. package/eventcatalog/src/components/FieldsExplorer/FieldFilters.tsx +225 -0
  23. package/eventcatalog/src/components/FieldsExplorer/FieldNodeGraph.tsx +521 -0
  24. package/eventcatalog/src/components/FieldsExplorer/FieldsExplorer.tsx +501 -0
  25. package/eventcatalog/src/components/FieldsExplorer/FieldsTable.tsx +236 -0
  26. package/eventcatalog/src/enterprise/fields/field-extractor.test.ts +241 -0
  27. package/eventcatalog/src/enterprise/fields/field-extractor.ts +183 -0
  28. package/eventcatalog/src/enterprise/fields/field-indexer.ts +131 -0
  29. package/eventcatalog/src/enterprise/fields/fields-db.test.ts +186 -0
  30. package/eventcatalog/src/enterprise/fields/fields-db.ts +453 -0
  31. package/eventcatalog/src/enterprise/fields/pages/api/fields.ts +43 -0
  32. package/eventcatalog/src/enterprise/fields/pages/fields.astro +19 -0
  33. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +23 -3
  34. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/[filename].astro +14 -16
  35. package/eventcatalog/src/utils/node-graphs/field-node-graph.ts +192 -0
  36. package/package.json +11 -9
@@ -0,0 +1,236 @@
1
+ import { useState } from 'react';
2
+ import { SearchX, Copy, Check, AlertTriangle } from 'lucide-react';
3
+ import { BoltIcon, ChatBubbleLeftIcon, MagnifyingGlassIcon } from '@heroicons/react/24/solid';
4
+ import { buildUrl } from '@utils/url-builder';
5
+
6
+ export interface FieldResult {
7
+ path: string;
8
+ type: string;
9
+ description: string;
10
+ required: boolean;
11
+ schemaFormat: string;
12
+ messageId: string;
13
+ messageVersion: string;
14
+ messageType: string;
15
+ messageName?: string;
16
+ messageSummary?: string;
17
+ messageOwners?: string[];
18
+ producers: { id: string; version: string; name?: string; summary?: string; owners?: string[] }[];
19
+ consumers: { id: string; version: string; name?: string; summary?: string; owners?: string[] }[];
20
+ usedInCount?: number;
21
+ conflicts?: { type: string; count: number }[];
22
+ }
23
+
24
+ export interface FieldsTableProps {
25
+ fields: FieldResult[];
26
+ onSelectField: (fieldPath: string) => void;
27
+ isLoading: boolean;
28
+ isScaleEnabled?: boolean;
29
+ }
30
+
31
+ const colorClasses: Record<string, string> = {
32
+ orange: 'text-orange-500',
33
+ blue: 'text-blue-500',
34
+ green: 'text-green-500',
35
+ gray: 'text-gray-500',
36
+ };
37
+
38
+ const getColorAndIconForMessageType = (type: string) => {
39
+ switch (type) {
40
+ case 'event':
41
+ return { color: 'orange', Icon: BoltIcon };
42
+ case 'command':
43
+ return { color: 'blue', Icon: ChatBubbleLeftIcon };
44
+ case 'query':
45
+ return { color: 'green', Icon: MagnifyingGlassIcon };
46
+ default:
47
+ return { color: 'gray', Icon: ChatBubbleLeftIcon };
48
+ }
49
+ };
50
+
51
+ function MessageBadge({ id, name, version, type }: { id: string; name?: string; version: string; type: string }) {
52
+ const { color, Icon } = getColorAndIconForMessageType(type);
53
+ const collection = type === 'query' ? 'queries' : `${type}s`;
54
+ const messageUrl = buildUrl(`/docs/${collection}/${id}/${version}`);
55
+ return (
56
+ <a
57
+ href={messageUrl}
58
+ onClick={(e) => e.stopPropagation()}
59
+ className="group/msg inline-flex items-center gap-1.5 text-xs hover:text-[rgb(var(--ec-accent))] transition-colors"
60
+ >
61
+ <Icon className={`h-3.5 w-3.5 ${colorClasses[color] || 'text-gray-500'} flex-shrink-0`} />
62
+ <span className="truncate max-w-[160px] text-[rgb(var(--ec-page-text))] group-hover/msg:text-[rgb(var(--ec-accent))]">
63
+ {name || id}
64
+ </span>
65
+ <span className="text-[rgb(var(--ec-icon-color))]">v{version}</span>
66
+ </a>
67
+ );
68
+ }
69
+
70
+ function FieldPathCell({ path }: { path: string }) {
71
+ const [copied, setCopied] = useState(false);
72
+
73
+ return (
74
+ <span className="inline-flex items-center gap-1 font-mono text-[rgb(var(--ec-page-text))]">
75
+ {path}
76
+ <button
77
+ className={`opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-[rgb(var(--ec-content-hover))]`}
78
+ onClick={(e) => {
79
+ e.stopPropagation();
80
+ navigator.clipboard.writeText(path);
81
+ setCopied(true);
82
+ setTimeout(() => setCopied(false), 1500);
83
+ }}
84
+ title="Copy field path"
85
+ >
86
+ {copied ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3 text-[rgb(var(--ec-icon-color))]" />}
87
+ </button>
88
+ </span>
89
+ );
90
+ }
91
+
92
+ export default function FieldsTable({ fields, onSelectField, isLoading, isScaleEnabled = false }: FieldsTableProps) {
93
+ if (isLoading) {
94
+ return (
95
+ <div className="flex-1 flex items-center justify-center">
96
+ <div className="flex flex-col items-center gap-3">
97
+ <div className="w-8 h-8 border-2 border-[rgb(var(--ec-accent))] border-t-transparent rounded-full animate-spin" />
98
+ <span className="text-sm text-[rgb(var(--ec-page-text-muted))]">Loading fields...</span>
99
+ </div>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ if (fields.length === 0) {
105
+ return (
106
+ <div className="flex-1 flex items-center justify-center">
107
+ <div className="flex flex-col items-center justify-center text-[rgb(var(--ec-page-text-muted))]">
108
+ <SearchX className="w-10 h-10 text-[rgb(var(--ec-icon-color))] mb-3 opacity-50" />
109
+ <p className="text-sm font-medium text-[rgb(var(--ec-page-text-muted))]">No schema fields found</p>
110
+ <p className="text-xs text-[rgb(var(--ec-icon-color))] mt-1 max-w-xs text-center">
111
+ Add <code className="px-1 py-0.5 rounded bg-[rgb(var(--ec-content-hover))] font-mono text-[11px]">schemaPath</code> to
112
+ your events, commands, or queries to get started.
113
+ </p>
114
+ </div>
115
+ </div>
116
+ );
117
+ }
118
+
119
+ return (
120
+ <div className="flex-1 overflow-auto px-6">
121
+ <table className="min-w-full divide-y divide-[rgb(var(--ec-page-border))]">
122
+ <thead className="sticky top-0 z-10 bg-[rgb(var(--ec-page-bg))]">
123
+ <tr>
124
+ <th className="px-4 py-2.5 text-left text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider">
125
+ Field Path
126
+ </th>
127
+ <th className="px-4 py-2.5 text-left text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider">
128
+ Type
129
+ </th>
130
+ <th className="px-4 py-2.5 text-left text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider">
131
+ Message
132
+ </th>
133
+ <th className="px-4 py-2.5 text-left text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider">
134
+ Format
135
+ </th>
136
+ <th className="px-4 py-2.5 text-left text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider">
137
+ Required
138
+ </th>
139
+ {isScaleEnabled && (
140
+ <th className="px-4 py-2.5 text-left text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider">
141
+ Consistency
142
+ </th>
143
+ )}
144
+ {isScaleEnabled && (
145
+ <th className="px-4 py-2.5 text-left text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider">
146
+ Used In
147
+ </th>
148
+ )}
149
+ <th className="px-4 py-2.5 text-left text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider">
150
+ Owners
151
+ </th>
152
+ </tr>
153
+ </thead>
154
+ <tbody className="divide-y divide-[rgb(var(--ec-page-border)/0.5)]">
155
+ {fields.map((field, index) => {
156
+ const rowKey = `${field.path}-${field.messageId}-${field.messageVersion}-${index}`;
157
+ const owners = field.messageOwners || [];
158
+
159
+ return (
160
+ <tr
161
+ key={rowKey}
162
+ className="group cursor-pointer transition-colors hover:bg-[rgb(var(--ec-content-hover))]"
163
+ onClick={() => onSelectField(field.path)}
164
+ >
165
+ <td className="px-4 py-3 text-sm">
166
+ <FieldPathCell path={field.path} />
167
+ </td>
168
+ <td className="px-4 py-3 text-sm font-mono text-[rgb(var(--ec-page-text-muted))]">{field.type}</td>
169
+ <td className="px-4 py-3 text-sm">
170
+ <MessageBadge
171
+ id={field.messageId}
172
+ name={field.messageName}
173
+ version={field.messageVersion}
174
+ type={field.messageType}
175
+ />
176
+ </td>
177
+ <td className="px-4 py-3 text-sm text-[rgb(var(--ec-page-text-muted))]">{field.schemaFormat}</td>
178
+ <td className="px-4 py-3 text-sm">
179
+ {field.required && (
180
+ <span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-red-500/10 text-red-500 border border-red-500/20">
181
+ Required
182
+ </span>
183
+ )}
184
+ </td>
185
+ {isScaleEnabled && (
186
+ <td className="px-4 py-3 text-sm">
187
+ {field.conflicts && field.conflicts.length > 1 ? (
188
+ <div
189
+ className="flex items-center gap-1.5"
190
+ title={field.conflicts.map((c) => `${c.type} (${c.count})`).join(', ')}
191
+ >
192
+ <AlertTriangle className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
193
+ <span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/10 text-amber-600 border border-amber-500/20">
194
+ {field.conflicts.length} types
195
+ </span>
196
+ </div>
197
+ ) : (
198
+ <span className="text-[10px] text-green-500 font-medium">Consistent</span>
199
+ )}
200
+ </td>
201
+ )}
202
+ {isScaleEnabled && (
203
+ <td className="px-4 py-3 text-sm">
204
+ <span className="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-[rgb(var(--ec-accent)/0.1)] text-[rgb(var(--ec-accent))]">
205
+ {field.usedInCount || 1} {(field.usedInCount || 1) === 1 ? 'schema' : 'schemas'}
206
+ </span>
207
+ </td>
208
+ )}
209
+ <td className="px-4 py-3 text-sm">
210
+ {owners.length > 0 ? (
211
+ <div className="flex items-center gap-1 flex-wrap">
212
+ {owners.slice(0, 2).map((owner) => (
213
+ <span
214
+ key={owner}
215
+ className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[rgb(var(--ec-accent)/0.1)] text-[rgb(var(--ec-accent))] truncate max-w-[100px]"
216
+ title={owner}
217
+ >
218
+ {owner}
219
+ </span>
220
+ ))}
221
+ {owners.length > 2 && (
222
+ <span className="text-[10px] text-[rgb(var(--ec-page-text-muted))]">+{owners.length - 2}</span>
223
+ )}
224
+ </div>
225
+ ) : (
226
+ <span className="text-[rgb(var(--ec-page-text-muted))] text-xs">-</span>
227
+ )}
228
+ </td>
229
+ </tr>
230
+ );
231
+ })}
232
+ </tbody>
233
+ </table>
234
+ </div>
235
+ );
236
+ }
@@ -0,0 +1,241 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractSchemaFieldsDeep } from './field-extractor';
3
+
4
+ describe('extractSchemaFieldsDeep', () => {
5
+ describe('JSON Schema', () => {
6
+ it('extracts top-level properties with their types', () => {
7
+ const schema = JSON.stringify({
8
+ type: 'object',
9
+ properties: {
10
+ orderId: { type: 'string', description: 'The order ID' },
11
+ amount: { type: 'number' },
12
+ },
13
+ });
14
+ const fields = extractSchemaFieldsDeep(schema, 'json-schema');
15
+ expect(fields).toEqual([
16
+ { path: 'orderId', type: 'string', description: 'The order ID', required: false },
17
+ { path: 'amount', type: 'number', description: '', required: false },
18
+ ]);
19
+ });
20
+
21
+ it('marks fields as required when listed in the JSON Schema required array', () => {
22
+ const schema = JSON.stringify({
23
+ type: 'object',
24
+ required: ['orderId'],
25
+ properties: {
26
+ orderId: { type: 'string' },
27
+ notes: { type: 'string' },
28
+ },
29
+ });
30
+ const fields = extractSchemaFieldsDeep(schema, 'json-schema');
31
+ expect(fields[0].required).toBe(true);
32
+ expect(fields[1].required).toBe(false);
33
+ });
34
+
35
+ it('extracts nested object fields with dot-notation paths', () => {
36
+ const schema = JSON.stringify({
37
+ type: 'object',
38
+ properties: {
39
+ address: {
40
+ type: 'object',
41
+ properties: {
42
+ street: { type: 'string' },
43
+ city: { type: 'string' },
44
+ },
45
+ },
46
+ },
47
+ });
48
+ const fields = extractSchemaFieldsDeep(schema, 'json-schema');
49
+ expect(fields.map((f) => f.path)).toEqual(['address', 'address.street', 'address.city']);
50
+ });
51
+
52
+ it('uses bracket notation for array item fields', () => {
53
+ const schema = JSON.stringify({
54
+ type: 'object',
55
+ properties: {
56
+ items: {
57
+ type: 'array',
58
+ items: {
59
+ type: 'object',
60
+ properties: {
61
+ productId: { type: 'string' },
62
+ quantity: { type: 'integer' },
63
+ },
64
+ },
65
+ },
66
+ },
67
+ });
68
+ const fields = extractSchemaFieldsDeep(schema, 'json-schema');
69
+ expect(fields.map((f) => f.path)).toEqual(['items', 'items[].productId', 'items[].quantity']);
70
+ });
71
+
72
+ it('resolves local $ref references within the same schema', () => {
73
+ const schema = JSON.stringify({
74
+ type: 'object',
75
+ properties: {
76
+ billing: { $ref: '#/definitions/Address' },
77
+ },
78
+ definitions: {
79
+ Address: {
80
+ type: 'object',
81
+ properties: {
82
+ street: { type: 'string' },
83
+ zip: { type: 'string' },
84
+ },
85
+ },
86
+ },
87
+ });
88
+ const fields = extractSchemaFieldsDeep(schema, 'json-schema');
89
+ expect(fields.map((f) => f.path)).toEqual(['billing', 'billing.street', 'billing.zip']);
90
+ });
91
+
92
+ it('skips external $ref references and returns only local fields', () => {
93
+ const schema = JSON.stringify({
94
+ type: 'object',
95
+ properties: {
96
+ orderId: { type: 'string' },
97
+ customer: { $ref: './common.json#/definitions/Customer' },
98
+ },
99
+ });
100
+ const fields = extractSchemaFieldsDeep(schema, 'json-schema');
101
+ expect(fields).toHaveLength(2);
102
+ expect(fields[0].path).toBe('orderId');
103
+ expect(fields[1].path).toBe('customer');
104
+ expect(fields[1].type).toBe('$ref');
105
+ });
106
+
107
+ it('merges allOf schemas and extracts combined properties', () => {
108
+ const schema = JSON.stringify({
109
+ allOf: [
110
+ { type: 'object', properties: { id: { type: 'string' } } },
111
+ { type: 'object', properties: { name: { type: 'string' } } },
112
+ ],
113
+ });
114
+ const fields = extractSchemaFieldsDeep(schema, 'json-schema');
115
+ expect(fields.map((f) => f.path)).toEqual(['id', 'name']);
116
+ });
117
+
118
+ it('returns empty array when schema content is malformed JSON', () => {
119
+ const fields = extractSchemaFieldsDeep('not valid json{{{', 'json-schema');
120
+ expect(fields).toEqual([]);
121
+ });
122
+
123
+ it('returns empty array when schema content is empty', () => {
124
+ const fields = extractSchemaFieldsDeep('', 'json-schema');
125
+ expect(fields).toEqual([]);
126
+ });
127
+ });
128
+
129
+ describe('Avro', () => {
130
+ it('extracts fields from a simple Avro record', () => {
131
+ const schema = JSON.stringify({
132
+ type: 'record',
133
+ name: 'Order',
134
+ fields: [
135
+ { name: 'orderId', type: 'string', doc: 'The order identifier' },
136
+ { name: 'amount', type: 'double' },
137
+ ],
138
+ });
139
+ const fields = extractSchemaFieldsDeep(schema, 'avro');
140
+ expect(fields).toEqual([
141
+ { path: 'orderId', type: 'string', description: 'The order identifier', required: true },
142
+ { path: 'amount', type: 'double', description: '', required: true },
143
+ ]);
144
+ });
145
+
146
+ it('handles Avro union types containing null as optional fields', () => {
147
+ const schema = JSON.stringify({
148
+ type: 'record',
149
+ name: 'Customer',
150
+ fields: [
151
+ { name: 'id', type: 'string' },
152
+ { name: 'nickname', type: ['null', 'string'] },
153
+ ],
154
+ });
155
+ const fields = extractSchemaFieldsDeep(schema, 'avro');
156
+ expect(fields[0].required).toBe(true);
157
+ expect(fields[1].required).toBe(false);
158
+ expect(fields[1].type).toBe('null | string');
159
+ });
160
+
161
+ it('extracts nested record fields with dot-notation paths', () => {
162
+ const schema = JSON.stringify({
163
+ type: 'record',
164
+ name: 'Order',
165
+ fields: [
166
+ {
167
+ name: 'customer',
168
+ type: {
169
+ type: 'record',
170
+ name: 'Customer',
171
+ fields: [
172
+ { name: 'name', type: 'string' },
173
+ { name: 'email', type: 'string' },
174
+ ],
175
+ },
176
+ },
177
+ ],
178
+ });
179
+ const fields = extractSchemaFieldsDeep(schema, 'avro');
180
+ expect(fields.map((f) => f.path)).toEqual(['customer', 'customer.name', 'customer.email']);
181
+ });
182
+
183
+ it('uses bracket notation for Avro array items', () => {
184
+ const schema = JSON.stringify({
185
+ type: 'record',
186
+ name: 'Order',
187
+ fields: [
188
+ {
189
+ name: 'items',
190
+ type: {
191
+ type: 'array',
192
+ items: {
193
+ type: 'record',
194
+ name: 'Item',
195
+ fields: [
196
+ { name: 'productId', type: 'string' },
197
+ { name: 'qty', type: 'int' },
198
+ ],
199
+ },
200
+ },
201
+ },
202
+ ],
203
+ });
204
+ const fields = extractSchemaFieldsDeep(schema, 'avro');
205
+ expect(fields.map((f) => f.path)).toEqual(['items', 'items[].productId', 'items[].qty']);
206
+ });
207
+ });
208
+
209
+ describe('Protobuf', () => {
210
+ it('extracts fields from a simple proto3 message', () => {
211
+ const proto = `
212
+ syntax = "proto3";
213
+ message Order {
214
+ string order_id = 1;
215
+ double amount = 2; // total amount
216
+ }
217
+ `;
218
+ const fields = extractSchemaFieldsDeep(proto, 'proto');
219
+ expect(fields).toEqual([
220
+ { path: 'order_id', type: 'string', description: '', required: false },
221
+ { path: 'amount', type: 'double', description: 'total amount', required: false },
222
+ ]);
223
+ });
224
+
225
+ it('marks repeated fields with their modifier in the type', () => {
226
+ const proto = `
227
+ syntax = "proto3";
228
+ message Order {
229
+ repeated string tags = 1;
230
+ }
231
+ `;
232
+ const fields = extractSchemaFieldsDeep(proto, 'proto');
233
+ expect(fields[0].type).toBe('repeated string');
234
+ });
235
+
236
+ it('returns empty array for empty proto content', () => {
237
+ const fields = extractSchemaFieldsDeep('', 'proto');
238
+ expect(fields).toEqual([]);
239
+ });
240
+ });
241
+ });
@@ -0,0 +1,183 @@
1
+ export interface DeepSchemaField {
2
+ path: string;
3
+ type: string;
4
+ description: string;
5
+ required: boolean;
6
+ }
7
+
8
+ export function extractSchemaFieldsDeep(content: string, format: string): DeepSchemaField[] {
9
+ if (!content) return [];
10
+
11
+ if (format === 'json-schema') {
12
+ return extractJsonSchemaFields(content);
13
+ }
14
+
15
+ if (format === 'avro') {
16
+ return extractAvroFields(content);
17
+ }
18
+
19
+ if (format === 'proto') {
20
+ return extractProtoFields(content);
21
+ }
22
+
23
+ return [];
24
+ }
25
+
26
+ function extractProtoFields(content: string): DeepSchemaField[] {
27
+ if (!content) return [];
28
+ const fields: DeepSchemaField[] = [];
29
+ const fieldRegex = /^\s*(repeated\s+|optional\s+|required\s+)?(\w+)\s+(\w+)\s*=\s*\d+\s*;(?:\s*\/\/\s*(.*))?/gm;
30
+ let match;
31
+ while ((match = fieldRegex.exec(content)) !== null) {
32
+ const modifier = (match[1] || '').trim();
33
+ const type = modifier ? `${modifier} ${match[2]}` : match[2];
34
+ fields.push({
35
+ path: match[3],
36
+ type,
37
+ description: match[4]?.trim() || '',
38
+ required: modifier === 'required',
39
+ });
40
+ }
41
+ return fields;
42
+ }
43
+
44
+ function extractAvroFields(content: string): DeepSchemaField[] {
45
+ try {
46
+ const schema = JSON.parse(content);
47
+ const fields: DeepSchemaField[] = [];
48
+ walkAvroRecord(schema, '', fields);
49
+ return fields;
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ function getAvroTypeName(type: any): string {
56
+ if (typeof type === 'string') return type;
57
+ if (Array.isArray(type)) {
58
+ return type.map((t) => (typeof t === 'string' ? t : t.type || 'complex')).join(' | ');
59
+ }
60
+ if (typeof type === 'object' && type !== null) {
61
+ if (type.type === 'array' && type.items) return `array<${getAvroTypeName(type.items)}>`;
62
+ if (type.type === 'record') return type.name || 'record';
63
+ return type.type || 'complex';
64
+ }
65
+ return 'unknown';
66
+ }
67
+
68
+ function walkAvroRecord(schema: any, prefix: string, fields: DeepSchemaField[]): void {
69
+ if (!schema.fields || !Array.isArray(schema.fields)) return;
70
+
71
+ for (const field of schema.fields) {
72
+ const path = prefix ? `${prefix}.${field.name}` : field.name;
73
+ const isOptional = Array.isArray(field.type) && field.type.includes('null');
74
+ const typeName = getAvroTypeName(field.type);
75
+
76
+ fields.push({
77
+ path,
78
+ type: typeName,
79
+ description: field.doc || '',
80
+ required: !isOptional,
81
+ });
82
+
83
+ // Recurse into nested records
84
+ const innerType = Array.isArray(field.type)
85
+ ? field.type.find((t: any) => typeof t === 'object' && t.type === 'record')
86
+ : typeof field.type === 'object' && field.type.type === 'record'
87
+ ? field.type
88
+ : null;
89
+
90
+ if (innerType) {
91
+ walkAvroRecord(innerType, path, fields);
92
+ }
93
+
94
+ // Recurse into array items that are records
95
+ const arrayType = Array.isArray(field.type)
96
+ ? field.type.find((t: any) => typeof t === 'object' && t.type === 'array')
97
+ : typeof field.type === 'object' && field.type.type === 'array'
98
+ ? field.type
99
+ : null;
100
+
101
+ if (arrayType && typeof arrayType.items === 'object' && arrayType.items.type === 'record') {
102
+ walkAvroRecord(arrayType.items, `${path}[]`, fields);
103
+ }
104
+ }
105
+ }
106
+
107
+ function extractJsonSchemaFields(content: string): DeepSchemaField[] {
108
+ try {
109
+ const schema = JSON.parse(content);
110
+ const fields: DeepSchemaField[] = [];
111
+ walkJsonSchema(schema, '', schema.required || [], schema, fields);
112
+ return fields;
113
+ } catch {
114
+ return [];
115
+ }
116
+ }
117
+
118
+ function walkJsonSchema(node: any, prefix: string, requiredList: string[], rootSchema: any, fields: DeepSchemaField[]): void {
119
+ // Handle allOf by merging (resolve $ref entries first)
120
+ if (node.allOf && Array.isArray(node.allOf)) {
121
+ const merged: any = { type: 'object', properties: {}, required: [] };
122
+ for (let sub of node.allOf) {
123
+ if (sub.$ref) {
124
+ const resolved = resolveLocalRef(sub.$ref, rootSchema);
125
+ if (resolved) sub = resolved;
126
+ else continue;
127
+ }
128
+ Object.assign(merged.properties, sub.properties || {});
129
+ merged.required.push(...(sub.required || []));
130
+ }
131
+ walkJsonSchema(merged, prefix, merged.required, rootSchema, fields);
132
+ return;
133
+ }
134
+
135
+ if (!node.properties) return;
136
+
137
+ for (const [name, prop] of Object.entries(node.properties) as [string, any][]) {
138
+ const path = prefix ? `${prefix}.${name}` : name;
139
+ const isRequired = requiredList.includes(name);
140
+
141
+ // Resolve $ref
142
+ if (prop.$ref) {
143
+ const resolved = resolveLocalRef(prop.$ref, rootSchema);
144
+ if (resolved) {
145
+ const type = resolved.type || 'object';
146
+ fields.push({ path, type, description: resolved.description || '', required: isRequired });
147
+ if (resolved.properties) {
148
+ walkJsonSchema(resolved, path, resolved.required || [], rootSchema, fields);
149
+ }
150
+ } else {
151
+ // External ref — add as-is
152
+ fields.push({ path, type: '$ref', description: '', required: isRequired });
153
+ }
154
+ continue;
155
+ }
156
+
157
+ const type = prop.type || (prop.enum ? 'enum' : prop.$ref ? '$ref' : 'object');
158
+ fields.push({ path, type, description: prop.description || '', required: isRequired });
159
+
160
+ // Recurse into nested objects
161
+ if (prop.type === 'object' && prop.properties) {
162
+ walkJsonSchema(prop, path, prop.required || [], rootSchema, fields);
163
+ }
164
+
165
+ // Recurse into array items
166
+ if (prop.type === 'array' && prop.items) {
167
+ if (prop.items.type === 'object' && prop.items.properties) {
168
+ walkJsonSchema(prop.items, `${path}[]`, prop.items.required || [], rootSchema, fields);
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ function resolveLocalRef(ref: string, rootSchema: any): any {
175
+ if (!ref.startsWith('#/')) return null;
176
+ const parts = ref.replace('#/', '').split('/');
177
+ let current = rootSchema;
178
+ for (const part of parts) {
179
+ current = current?.[part];
180
+ if (!current) return null;
181
+ }
182
+ return current;
183
+ }