@workos/oagen-emitters 0.3.0 → 0.5.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 (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +15 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +12 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -12737
  13. package/dist/plugin-BSop9f9z.mjs +21471 -0
  14. package/dist/plugin-BSop9f9z.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +336 -0
  19. package/oagen.config.ts +5 -343
  20. package/package.json +10 -34
  21. package/smoke/sdk-dotnet.ts +45 -12
  22. package/src/dotnet/client.ts +89 -0
  23. package/src/dotnet/enums.ts +323 -0
  24. package/src/dotnet/fixtures.ts +236 -0
  25. package/src/dotnet/index.ts +248 -0
  26. package/src/dotnet/manifest.ts +36 -0
  27. package/src/dotnet/models.ts +320 -0
  28. package/src/dotnet/naming.ts +368 -0
  29. package/src/dotnet/resources.ts +943 -0
  30. package/src/dotnet/tests.ts +713 -0
  31. package/src/dotnet/type-map.ts +228 -0
  32. package/src/dotnet/wrappers.ts +197 -0
  33. package/src/go/client.ts +35 -3
  34. package/src/go/enums.ts +4 -0
  35. package/src/go/index.ts +15 -7
  36. package/src/go/models.ts +6 -1
  37. package/src/go/naming.ts +5 -17
  38. package/src/go/resources.ts +534 -73
  39. package/src/go/tests.ts +39 -3
  40. package/src/go/type-map.ts +8 -3
  41. package/src/go/wrappers.ts +79 -21
  42. package/src/index.ts +15 -0
  43. package/src/kotlin/client.ts +58 -0
  44. package/src/kotlin/enums.ts +189 -0
  45. package/src/kotlin/index.ts +92 -0
  46. package/src/kotlin/manifest.ts +55 -0
  47. package/src/kotlin/models.ts +486 -0
  48. package/src/kotlin/naming.ts +229 -0
  49. package/src/kotlin/overrides.ts +25 -0
  50. package/src/kotlin/resources.ts +998 -0
  51. package/src/kotlin/tests.ts +1133 -0
  52. package/src/kotlin/type-map.ts +123 -0
  53. package/src/kotlin/wrappers.ts +168 -0
  54. package/src/node/client.ts +84 -7
  55. package/src/node/field-plan.ts +12 -14
  56. package/src/node/fixtures.ts +39 -3
  57. package/src/node/index.ts +1 -0
  58. package/src/node/models.ts +281 -37
  59. package/src/node/resources.ts +319 -95
  60. package/src/node/tests.ts +108 -29
  61. package/src/node/type-map.ts +1 -31
  62. package/src/node/utils.ts +96 -6
  63. package/src/node/wrappers.ts +31 -1
  64. package/src/php/client.ts +11 -3
  65. package/src/php/models.ts +0 -33
  66. package/src/php/naming.ts +2 -21
  67. package/src/php/resources.ts +275 -19
  68. package/src/php/tests.ts +118 -18
  69. package/src/php/type-map.ts +16 -2
  70. package/src/php/wrappers.ts +7 -2
  71. package/src/plugin.ts +50 -0
  72. package/src/python/client.ts +50 -32
  73. package/src/python/enums.ts +35 -10
  74. package/src/python/index.ts +35 -27
  75. package/src/python/models.ts +139 -2
  76. package/src/python/naming.ts +2 -22
  77. package/src/python/resources.ts +234 -17
  78. package/src/python/tests.ts +260 -16
  79. package/src/python/type-map.ts +16 -2
  80. package/src/ruby/client.ts +238 -0
  81. package/src/ruby/enums.ts +149 -0
  82. package/src/ruby/index.ts +93 -0
  83. package/src/ruby/manifest.ts +35 -0
  84. package/src/ruby/models.ts +360 -0
  85. package/src/ruby/naming.ts +187 -0
  86. package/src/ruby/rbi.ts +313 -0
  87. package/src/ruby/resources.ts +799 -0
  88. package/src/ruby/tests.ts +459 -0
  89. package/src/ruby/type-map.ts +97 -0
  90. package/src/ruby/wrappers.ts +161 -0
  91. package/src/shared/model-utils.ts +357 -16
  92. package/src/shared/naming-utils.ts +83 -0
  93. package/src/shared/non-spec-services.ts +13 -0
  94. package/src/shared/resolved-ops.ts +75 -1
  95. package/src/shared/wrapper-utils.ts +12 -1
  96. package/test/dotnet/client.test.ts +121 -0
  97. package/test/dotnet/enums.test.ts +193 -0
  98. package/test/dotnet/errors.test.ts +9 -0
  99. package/test/dotnet/manifest.test.ts +82 -0
  100. package/test/dotnet/models.test.ts +258 -0
  101. package/test/dotnet/resources.test.ts +387 -0
  102. package/test/dotnet/tests.test.ts +202 -0
  103. package/test/entrypoint.test.ts +89 -0
  104. package/test/go/client.test.ts +6 -6
  105. package/test/go/resources.test.ts +156 -7
  106. package/test/kotlin/models.test.ts +135 -0
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/kotlin/tests.test.ts +176 -0
  109. package/test/node/client.test.ts +74 -0
  110. package/test/node/models.test.ts +134 -1
  111. package/test/node/resources.test.ts +343 -34
  112. package/test/node/utils.test.ts +140 -0
  113. package/test/php/client.test.ts +2 -1
  114. package/test/php/models.test.ts +5 -4
  115. package/test/php/resources.test.ts +103 -0
  116. package/test/php/tests.test.ts +67 -0
  117. package/test/plugin.test.ts +50 -0
  118. package/test/python/client.test.ts +56 -0
  119. package/test/python/models.test.ts +99 -0
  120. package/test/python/resources.test.ts +294 -0
  121. package/test/python/tests.test.ts +91 -0
  122. package/test/ruby/client.test.ts +81 -0
  123. package/test/ruby/resources.test.ts +386 -0
  124. package/test/shared/resolved-ops.test.ts +122 -0
  125. package/tsdown.config.ts +1 -1
  126. package/dist/index.mjs.map +0 -1
  127. package/scripts/generate-php.js +0 -13
  128. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -0,0 +1,123 @@
1
+ import type { TypeRef, PrimitiveType, UnionType } from '@workos/oagen';
2
+ import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
3
+ import { className } from './naming.js';
4
+ import { enumCanonicalMap } from './enums.js';
5
+
6
+ /** Resolve an enum name through the canonical map (identity when no alias). */
7
+ function resolveEnumName(name: string): string {
8
+ return className(enumCanonicalMap.get(name) ?? name);
9
+ }
10
+
11
+ /**
12
+ * Map an IR TypeRef to a non-nullable Kotlin type expression.
13
+ *
14
+ * Kotlin's type system marks nullability at use-sites (`T?`). Optional fields
15
+ * on generated models append `?` to the output of this function.
16
+ */
17
+ export function mapTypeRef(ref: TypeRef): string {
18
+ return irMapTypeRef<string>(ref, {
19
+ primitive: mapPrimitive,
20
+ array: (_ref, items) => `List<${items}>`,
21
+ model: (r) => className(r.name),
22
+ enum: (r) => resolveEnumName(r.name),
23
+ union: (r, variants) => joinUnionVariants(r, variants),
24
+ nullable: (_ref, inner) => (inner.endsWith('?') ? inner : `${inner}?`),
25
+ literal: (r) => {
26
+ if (r.value === null) return 'Any?';
27
+ if (typeof r.value === 'string') return 'String';
28
+ if (typeof r.value === 'number') return Number.isInteger(r.value) ? 'Long' : 'Double';
29
+ if (typeof r.value === 'boolean') return 'Boolean';
30
+ return 'Any';
31
+ },
32
+ map: (_ref, value) => `Map<String, ${value}>`,
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Map an IR TypeRef to a nullable Kotlin type expression (always appends `?`).
38
+ * Useful for optional fields on models / request options.
39
+ */
40
+ export function mapTypeRefOptional(ref: TypeRef): string {
41
+ const baseType = mapTypeRef(ref);
42
+ return baseType.endsWith('?') ? baseType : `${baseType}?`;
43
+ }
44
+
45
+ /**
46
+ * Is the given IR TypeRef a primitive value? Useful when deciding whether a
47
+ * nullable field needs an explicit `= null` default.
48
+ */
49
+ export function isValueTypeRef(ref: TypeRef): boolean {
50
+ if (ref.kind === 'enum') return true;
51
+ if (ref.kind === 'primitive') {
52
+ if (ref.format === 'date-time') return true;
53
+ switch (ref.type) {
54
+ case 'integer':
55
+ case 'number':
56
+ case 'boolean':
57
+ return true;
58
+ default:
59
+ return false;
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+
65
+ function mapPrimitive(ref: PrimitiveType): string {
66
+ if (ref.format === 'binary') return 'ByteArray';
67
+ if (ref.format === 'int32') return 'Int';
68
+ if (ref.format === 'int64') return 'Long';
69
+ if (ref.format === 'date-time') return 'OffsetDateTime';
70
+ switch (ref.type) {
71
+ case 'string':
72
+ return 'String';
73
+ case 'integer':
74
+ return 'Long';
75
+ case 'number':
76
+ return 'Double';
77
+ case 'boolean':
78
+ return 'Boolean';
79
+ case 'unknown':
80
+ return 'Any';
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Track discriminated unions so the model generator can emit the appropriate
86
+ * Jackson `@JsonTypeInfo` / `@JsonSubTypes` annotations on the sealed parent.
87
+ */
88
+ export const discriminatedUnions = new Map<
89
+ string,
90
+ { property: string; mapping: Record<string, string>; variantTypes: string[] }
91
+ >();
92
+
93
+ function joinUnionVariants(ref: UnionType, variants: string[]): string {
94
+ if (ref.compositionKind === 'allOf') {
95
+ return variants[0] ?? 'Any';
96
+ }
97
+ const unique = [...new Set(variants)];
98
+ if (unique.length === 1) return unique[0];
99
+
100
+ if (ref.discriminator && ref.discriminator.mapping) {
101
+ const baseName = unique[0];
102
+ discriminatedUnions.set(baseName, {
103
+ property: ref.discriminator.property,
104
+ mapping: ref.discriminator.mapping,
105
+ variantTypes: unique,
106
+ });
107
+ // Use the base sealed type; Jackson @JsonTypeInfo handles variant selection.
108
+ return baseName;
109
+ }
110
+
111
+ // Non-discriminated unions fall back to the Kotlin top type. A generic
112
+ // AnyOf<> is planned for a future phase if emitter tests prove it necessary.
113
+ return 'Any';
114
+ }
115
+
116
+ /** Kotlin imports implied by a given type expression. Caller collects into a set. */
117
+ export function implicitImportsFor(kotlinType: string): string[] {
118
+ const imports: string[] = [];
119
+ if (/\bOffsetDateTime\b/.test(kotlinType)) {
120
+ imports.push('java.time.OffsetDateTime');
121
+ }
122
+ return imports;
123
+ }
@@ -0,0 +1,168 @@
1
+ import type { EmitterContext, ResolvedOperation, ResolvedWrapper, Parameter } from '@workos/oagen';
2
+ import { className, propertyName, ktLiteral, clientFieldExpression, escapeReserved } from './naming.js';
3
+ import { mapTypeRef, mapTypeRefOptional } from './type-map.js';
4
+ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
5
+ import { sortPathParamsByTemplateOrder } from './resources.js';
6
+
7
+ /**
8
+ * Emit Kotlin wrapper methods for a union-split operation. Each wrapper
9
+ * method takes only the fields it needs for its variant, fills in the
10
+ * operation-level defaults and client-inferred values, and posts to the
11
+ * underlying operation.
12
+ *
13
+ * Returns a list of lines (with leading indentation suitable for inclusion
14
+ * inside the service class body).
15
+ */
16
+ export function generateWrapperMethods(resolvedOp: ResolvedOperation, ctx: EmitterContext): string[] {
17
+ if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
18
+
19
+ const out: string[] = [];
20
+ for (const wrapper of resolvedOp.wrappers) {
21
+ if (out.length > 0) out.push('');
22
+ for (const line of emitWrapperMethod(resolvedOp, wrapper, ctx)) out.push(line);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapper, ctx: EmitterContext): string[] {
28
+ const op = resolvedOp.operation;
29
+ const method = propertyName(wrapper.name);
30
+ const resolvedParams = resolveWrapperParams(wrapper, ctx);
31
+ const responseClass = wrapper.responseModelName ? className(wrapper.responseModelName) : null;
32
+
33
+ const pathParams = sortPathParamsByTemplateOrder(op);
34
+
35
+ const lines: string[] = [];
36
+
37
+ // Build KDoc from operation description + @param docs for each wrapper param.
38
+ const kdocLines: string[] = [];
39
+ const opDesc = (op.description ?? '').trim();
40
+ const wrapperHumanName = method.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
41
+ if (opDesc) {
42
+ kdocLines.push(opDesc.split('\n')[0]);
43
+ } else {
44
+ kdocLines.push(`${wrapperHumanName.charAt(0).toUpperCase()}${wrapperHumanName.slice(1)}.`);
45
+ }
46
+ const paramDocs: string[] = [];
47
+ for (const pp of pathParams) {
48
+ if (pp.description?.trim()) {
49
+ paramDocs.push(`@param ${propertyName(pp.name)} ${escapeKdoc(pp.description.split('\n')[0].trim())}`);
50
+ }
51
+ }
52
+ for (const rp of resolvedParams) {
53
+ const desc = rp.field?.description?.trim();
54
+ if (desc) {
55
+ paramDocs.push(`@param ${propertyName(rp.paramName)} ${escapeKdoc(desc.split('\n')[0])}`);
56
+ }
57
+ }
58
+ if (responseClass) {
59
+ paramDocs.push(`@return the ${responseClass}`);
60
+ }
61
+ if (paramDocs.length > 0 || kdocLines.length > 0) {
62
+ lines.push(' /**');
63
+ for (const l of kdocLines) lines.push(` * ${escapeKdoc(l)}`);
64
+ if (paramDocs.length > 0) {
65
+ lines.push(' *');
66
+ for (const p of paramDocs) lines.push(` * ${p}`);
67
+ }
68
+ lines.push(' */');
69
+ }
70
+
71
+ lines.push(' @JvmOverloads');
72
+
73
+ // Build the method parameter list: path params, wrapper params, requestOptions
74
+ const params: string[] = [];
75
+ for (const pp of pathParams) params.push(` ${propertyName(pp.name)}: String`);
76
+ for (const rp of resolvedParams) {
77
+ const paramName = propertyName(rp.paramName);
78
+ const kotlinType = rp.field
79
+ ? rp.isOptional
80
+ ? mapTypeRefOptional(rp.field.type)
81
+ : mapTypeRef(rp.field.type)
82
+ : rp.isOptional
83
+ ? 'String?'
84
+ : 'String';
85
+ const trailer = rp.isOptional ? ' = null' : '';
86
+ params.push(` ${paramName}: ${kotlinType}${trailer}`);
87
+ }
88
+ params.push(' requestOptions: RequestOptions? = null');
89
+
90
+ const returnClause = responseClass ? `: ${responseClass}` : '';
91
+ if (params.length === 1) {
92
+ const single = params[0].replace(/^\s+/, '');
93
+ lines.push(` fun ${escapeReserved(method)}(${single})${returnClause} {`);
94
+ } else {
95
+ lines.push(` fun ${escapeReserved(method)}(`);
96
+ for (let i = 0; i < params.length; i++) {
97
+ const suffix = i === params.length - 1 ? '' : ',';
98
+ lines.push(`${params[i]}${suffix}`);
99
+ }
100
+ lines.push(` )${returnClause} {`);
101
+ }
102
+
103
+ // Build body using bodyOf() — consistent with non-wrapper methods.
104
+ // bodyOf() automatically drops null optional values.
105
+ const bodyEntries: string[] = [];
106
+ for (const rp of resolvedParams) {
107
+ const paramName = propertyName(rp.paramName);
108
+ bodyEntries.push(` ${ktLiteral(rp.paramName)} to ${paramName}`);
109
+ }
110
+ for (const [k, v] of Object.entries(wrapper.defaults ?? {})) {
111
+ bodyEntries.push(` ${ktLiteral(k)} to ${ktLiteral(v)}`);
112
+ }
113
+ for (const k of wrapper.inferFromClient ?? []) {
114
+ bodyEntries.push(` ${ktLiteral(k)} to workos.${clientFieldExpression(k)}`);
115
+ }
116
+ if (bodyEntries.length > 0) {
117
+ lines.push(` val body =`);
118
+ lines.push(` bodyOf(`);
119
+ for (let i = 0; i < bodyEntries.length; i++) {
120
+ const sep = i === bodyEntries.length - 1 ? '' : ',';
121
+ lines.push(` ${bodyEntries[i]}${sep}`);
122
+ }
123
+ lines.push(` )`);
124
+ } else {
125
+ lines.push(` val body = linkedMapOf<String, Any?>()`);
126
+ }
127
+
128
+ const pathExpr = buildPathExpr(op.path, pathParams);
129
+ const httpMethod = op.httpMethod.toUpperCase();
130
+
131
+ lines.push(` val config =`);
132
+ lines.push(` RequestConfig(`);
133
+ lines.push(` method = ${ktLiteral(httpMethod)},`);
134
+ lines.push(` path = ${pathExpr},`);
135
+ lines.push(` body = body,`);
136
+ if (op.requestBodyEncoding === 'form-urlencoded') {
137
+ // Some ops (SSO token, User Management authenticate) are form-encoded.
138
+ // Rewrite as formBody mapping string→string instead of JSON body.
139
+ // Fallback: leave body as JSON — the API accepts JSON for these too.
140
+ }
141
+ lines.push(` requestOptions = requestOptions`);
142
+ lines.push(` )`);
143
+
144
+ if (responseClass) {
145
+ lines.push(` return workos.baseClient.request(config, ${responseClass}::class.java)`);
146
+ } else {
147
+ lines.push(` workos.baseClient.requestVoid(config)`);
148
+ }
149
+
150
+ lines.push(' }');
151
+ return lines;
152
+ }
153
+
154
+ function escapeKdoc(s: string): string {
155
+ return s.replace(/\*\//g, '*\u200b/');
156
+ }
157
+
158
+ function buildPathExpr(path: string, pathParams: Parameter[]): string {
159
+ if (pathParams.length === 0) return ktLiteral(path);
160
+ let result = path;
161
+ for (const pp of pathParams) {
162
+ const placeholder = `{${pp.name}}`;
163
+ const propName = propertyName(pp.name);
164
+ const replacement = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(propName) ? `\$${propName}` : `\${${propName}}`;
165
+ result = result.replaceAll(placeholder, replacement);
166
+ }
167
+ return `"${result.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
168
+ }
@@ -1,5 +1,7 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import type { ApiSpec, AuthScheme, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
- import { collectReferencedNames } from '@workos/oagen';
4
+
3
5
  import { fileName, resolveServiceDir, servicePropertyName, resolveInterfaceName, wireInterfaceName } from './naming.js';
4
6
  import {
5
7
  docComment,
@@ -7,6 +9,7 @@ import {
7
9
  isServiceCoveredByExisting,
8
10
  isListMetadataModel,
9
11
  isListWrapperModel,
12
+ computeNonEventReachable,
10
13
  } from './utils.js';
11
14
  import { resolveResourceClassName } from './resources.js';
12
15
 
@@ -85,6 +88,15 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
85
88
  const resolvedName = resolveResourceClassName(service, ctx);
86
89
  const propName = servicePropertyName(resolvedName);
87
90
  if (existingProps.has(propName)) continue;
91
+ // Propagate `@deprecated` from the service class to the property so
92
+ // IDEs surface the strikethrough at `workos.xyz` access sites, not
93
+ // just when users `new Xyz()` directly. TS's deprecation-lint reads
94
+ // the property JSDoc, not the underlying type declaration.
95
+ const classDeprecation = ctx.apiSurface?.classes?.[resolvedName]?.deprecationMessage;
96
+ if (classDeprecation !== undefined) {
97
+ const body = classDeprecation ? ` ${classDeprecation}` : '';
98
+ lines.push(` /** @deprecated${body} */`);
99
+ }
88
100
  lines.push(` readonly ${propName} = new ${resolvedName}(this);`);
89
101
  }
90
102
 
@@ -175,12 +187,12 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
175
187
  // Models -> service directories
176
188
  // Skip list wrapper and list metadata models — they use shared List<T>/ListMetadata
177
189
  // from common utils, so no per-resource interface file is generated.
178
- // Also skip unreachable models — oagen only passes service-referenced models
179
- // to generateModels, so unreachable models have no interface file to export.
180
- const barrelReachable = collectReferencedNames(spec.services, spec.models);
190
+ // Also skip unreachable models — use the same non-event reachability as model
191
+ // generation so every barrel entry has a corresponding generated file.
192
+ const barrelReachable = computeNonEventReachable(spec.services, spec.models);
181
193
  for (const model of spec.models) {
182
194
  if (isListMetadataModel(model) || isListWrapperModel(model)) continue;
183
- if (!barrelReachable.models.has(model.name)) continue;
195
+ if (!barrelReachable.has(model.name)) continue;
184
196
  const service = modelToService.get(model.name);
185
197
  const dirName = resolveDir(service);
186
198
  if (!dirExports.has(dirName)) {
@@ -253,6 +265,71 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
253
265
  addBaselineExports(ctx.apiSurface.interfaces);
254
266
  addBaselineExports(ctx.apiSurface.typeAliases);
255
267
  addBaselineExports(ctx.apiSurface.enums);
268
+
269
+ // Preserve existing barrel entries: read the current barrel from the
270
+ // target directory and keep every `export * from './<stem>'` whose
271
+ // corresponding file still exists on disk. This prevents dropping
272
+ // hand-written types (e.g., Factor in multi-factor-auth) when a
273
+ // generated model in the same file causes a symbol collision.
274
+ if (ctx.targetDir) {
275
+ const interfacesDir = path.join(ctx.targetDir, 'src', dirName, 'interfaces');
276
+ try {
277
+ const barrelPath = path.join(interfacesDir, 'index.ts');
278
+ const barrelContent = fs.readFileSync(barrelPath, 'utf-8');
279
+ for (const line of barrelContent.split('\n')) {
280
+ const match = line.match(/^export \* from '\.\/(.*?)';?$/);
281
+ if (!match) continue;
282
+ const stem = match[1];
283
+ const exportLine = `export * from './${stem}';`;
284
+ if (exportSet.has(exportLine)) continue;
285
+ // Verify the referenced file still exists
286
+ const filePath = path.join(interfacesDir, `${stem}.ts`);
287
+ try {
288
+ fs.accessSync(filePath);
289
+ exportSet.add(exportLine);
290
+ } catch {
291
+ // File no longer exists — don't preserve stale entry
292
+ }
293
+ }
294
+ } catch {
295
+ // No existing barrel — nothing to preserve
296
+ }
297
+
298
+ // Also scan for NEW interface files not in the existing barrel or
299
+ // apiSurface (e.g., list wrappers, hand-written types added after
300
+ // the last generation).
301
+ const symbols = dirSymbols.get(dirName) ?? new Set<string>();
302
+ try {
303
+ for (const entry of fs.readdirSync(interfacesDir)) {
304
+ if (entry === 'index.ts') continue;
305
+ if (!entry.endsWith('.ts')) continue;
306
+ const stem = entry.replace(/\.ts$/, '');
307
+ const exportLine = `export * from './${stem}';`;
308
+ if (exportSet.has(exportLine)) continue;
309
+
310
+ // Extract exported symbol names from the file to check for conflicts
311
+ const content = fs.readFileSync(path.join(interfacesDir, entry), 'utf-8');
312
+ const exportedNames: string[] = [];
313
+ for (const m of content.matchAll(/export\s+(?:interface|type|enum|class|const|function)\s+(\w+)/g)) {
314
+ exportedNames.push(m[1]);
315
+ }
316
+
317
+ // Skip if any exported name collides with a symbol already
318
+ // claimed by any file (same or different directory)
319
+ const hasCollision = exportedNames.some((name) => globalExistingSymbols.has(name));
320
+ if (hasCollision) continue;
321
+
322
+ // Safe to add — register symbols and include in barrel
323
+ for (const name of exportedNames) {
324
+ symbols.add(name);
325
+ globalExistingSymbols.add(name);
326
+ }
327
+ exportSet.add(exportLine);
328
+ }
329
+ } catch {
330
+ // Directory doesn't exist in target — nothing to scan
331
+ }
332
+ }
256
333
  }
257
334
 
258
335
  // Deduplicate and sort
@@ -495,8 +572,8 @@ function generateBarrel(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
495
572
  // Filter to reachable models only: oagen's generateAllFiles passes only
496
573
  // service-referenced models to generateModels, so unreachable models
497
574
  // never get interface files. Exporting them here would create broken imports.
498
- const reachable = collectReferencedNames(spec.services, spec.models);
499
- const unassignedModels = spec.models.filter((m) => !modelToService.has(m.name) && reachable.models.has(m.name));
575
+ const reachable = computeNonEventReachable(spec.services, spec.models);
576
+ const unassignedModels = spec.models.filter((m) => !modelToService.has(m.name) && reachable.has(m.name));
500
577
  const commonEnums = spec.enums.filter((e) => {
501
578
  const enumService = findEnumService(e.name, spec.services);
502
579
  return !enumService;
@@ -582,7 +582,6 @@ function emitAssignment(lhs: string, expr: string, accessExpr: string, guard: Gu
582
582
  interface SerializerContext {
583
583
  modelToService: Map<string, string>;
584
584
  resolveDir: (irService: string | undefined) => string;
585
- useStringDates: boolean;
586
585
  dedup: Map<string, string>;
587
586
  skippedSerializeModels: Set<string>;
588
587
  ctx: EmitterContext;
@@ -614,31 +613,30 @@ export function buildSerializerImports(
614
613
  const depSerializerPath = `src/${depDir}/serializers/${fileName(dep)}.serializer.ts`;
615
614
  const depName = resolveInterfaceName(dep, sctx.ctx);
616
615
  const rel = relativeImport(serializerPath, depSerializerPath);
617
- lines.push(`import { deserialize${depName}, serialize${depName} } from '${rel}';`);
616
+ // Check the canonical name for dedup'd models
617
+ const canon = sctx.dedup.get(dep);
618
+ const depSkipSerialize =
619
+ sctx.skippedSerializeModels.has(dep) || (canon != null && sctx.skippedSerializeModels.has(canon));
620
+ if (depSkipSerialize) {
621
+ lines.push(`import { deserialize${depName} } from '${rel}';`);
622
+ } else {
623
+ lines.push(`import { deserialize${depName}, serialize${depName} } from '${rel}';`);
624
+ }
618
625
  }
619
626
  lines.push('');
620
627
  return lines;
621
628
  }
622
629
 
623
630
  /** Build the set of field names where format conversion should be skipped. */
624
- export function buildSkipFormatFields(
625
- model: Model,
626
- useStringDates: boolean,
627
- baselineDomain: BaselineInterface | undefined,
628
- ): Set<string> {
631
+ export function buildSkipFormatFields(model: Model, baselineDomain: BaselineInterface | undefined): Set<string> {
629
632
  const skipFormatFields = new Set<string>();
630
- if (useStringDates) {
631
- for (const field of model.fields) {
632
- if (hasDateTimeConversion(field.type)) {
633
- skipFormatFields.add(field.name);
634
- }
635
- }
636
- }
637
633
  if (baselineDomain) {
638
634
  for (const field of model.fields) {
639
635
  if (skipFormatFields.has(field.name)) continue;
640
636
  const baselineField = baselineDomain.fields?.[fieldName(field.name)];
641
637
  if (baselineField && !baselineField.type.includes('Date') && hasFormatConversion(field.type)) {
638
+ // Always convert date-time fields to Date regardless of baseline
639
+ if (hasDateTimeConversion(field.type)) continue;
642
640
  skipFormatFields.add(field.name);
643
641
  }
644
642
  }
@@ -48,15 +48,51 @@ export function generateFixtures(
48
48
  const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
49
49
  const files: { path: string; content: string }[] = [];
50
50
 
51
+ // Only generate fixtures for models reachable from non-event operations
52
+ const fixtureSeeds = new Set<string>();
53
+ for (const svc of spec.services) {
54
+ if (svc.name.toLowerCase() === 'events') continue;
55
+ for (const op of svc.operations) {
56
+ const collectFromRef = (t: import('@workos/oagen').TypeRef | undefined): void => {
57
+ if (!t) return;
58
+ if (t.kind === 'model') fixtureSeeds.add(t.name);
59
+ if (t.kind === 'array') collectFromRef(t.items);
60
+ if (t.kind === 'nullable') collectFromRef(t.inner);
61
+ if (t.kind === 'union') t.variants.forEach(collectFromRef);
62
+ };
63
+ collectFromRef(op.response);
64
+ collectFromRef(op.requestBody);
65
+ if (op.pagination?.itemType) collectFromRef(op.pagination.itemType);
66
+ }
67
+ }
68
+ const fixtureModelMap = new Map(spec.models.map((m: Model) => [m.name, m]));
69
+ const fixtureReachable = new Set<string>();
70
+ const fixtureQueue = [...fixtureSeeds];
71
+ while (fixtureQueue.length > 0) {
72
+ const name = fixtureQueue.pop()!;
73
+ if (fixtureReachable.has(name)) continue;
74
+ fixtureReachable.add(name);
75
+ const m = fixtureModelMap.get(name);
76
+ if (!m) continue;
77
+ for (const field of m.fields) {
78
+ const walk = (t: import('@workos/oagen').TypeRef): void => {
79
+ if (t.kind === 'model' && !fixtureReachable.has(t.name)) fixtureQueue.push(t.name);
80
+ if (t.kind === 'array') walk(t.items);
81
+ if (t.kind === 'nullable') walk(t.inner);
82
+ if (t.kind === 'union') t.variants.forEach(walk);
83
+ };
84
+ walk(field.type);
85
+ }
86
+ }
51
87
  const seenFixturePaths = new Set<string>();
52
88
  for (const model of spec.models) {
53
- // Skip redundant list-metadata and list-wrapper models (handled by shared types)
89
+ if (!fixtureReachable.has(model.name)) continue;
54
90
  if (isListMetadataModel(model)) continue;
55
91
  if (isListWrapperModel(model)) continue;
56
92
 
57
93
  const service = modelToService.get(model.name);
58
94
  const dirName = resolveDir(service);
59
- const fixturePath = `src/${dirName}/fixtures/${fileName(model.name)}.fixture.json`;
95
+ const fixturePath = `src/${dirName}/fixtures/${fileName(model.name)}.json`;
60
96
 
61
97
  // After noise suffix stripping, multiple models may resolve to the same
62
98
  // fixture path (e.g., OrganizationDto and Organization). Skip duplicates.
@@ -94,7 +130,7 @@ export function generateFixtures(
94
130
  },
95
131
  };
96
132
  files.push({
97
- path: `src/${serviceDir}/fixtures/list-${fileName(itemModel.name)}.fixture.json`,
133
+ path: `src/${serviceDir}/fixtures/list-${fileName(itemModel.name)}.json`,
98
134
  content: JSON.stringify(listFixture, null, 2),
99
135
  });
100
136
  }
package/src/node/index.ts CHANGED
@@ -16,6 +16,7 @@ import { generateEnums } from './enums.js';
16
16
  import { generateResources } from './resources.js';
17
17
  import { generateClient } from './client.js';
18
18
  import { generateErrors } from './errors.js';
19
+
19
20
  import { generateTests } from './tests.js';
20
21
  import { generateManifest } from './manifest.js';
21
22