Skip to content
This repository was archived by the owner on Oct 16, 2025. It is now read-only.

Commit ab26ee3

Browse files
authored
Add helper support for root operations (#5)
Add typed helper exports for Query, Mutation, and Subscription so generated TypeScript, Dart, Kotlin, and Swift clients enforce argument signatures. Regenerate emitted files to expose the helpers immediately.
1 parent 677fbb0 commit ab26ee3

File tree

8 files changed

+714
-0
lines changed

8 files changed

+714
-0
lines changed

scripts/fix-generated-types.mjs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,4 +222,89 @@ wrapReturns('Mutation');
222222

223223
content = content.replace(/^\s*_placeholder\??: [^;]+;\n/gm, '');
224224

225+
const ROOT_DEFINITIONS = ['Query', 'Mutation', 'Subscription'];
226+
227+
const helperMarkers = (root) => ({
228+
start: `// -- ${root} helper types (auto-generated)`,
229+
end: `// -- End ${root.toLowerCase()} helper types`,
230+
});
231+
232+
const removeRootHelpers = (root) => {
233+
const { start, end } = helperMarkers(root);
234+
const startIdx = content.indexOf(start);
235+
if (startIdx === -1) return;
236+
const endIdx = content.indexOf(end);
237+
if (endIdx === -1) return;
238+
const sliceEnd = content.indexOf('\n', endIdx + end.length);
239+
const finalEnd = sliceEnd === -1 ? content.length : sliceEnd + 1;
240+
content = content.slice(0, startIdx) + content.slice(finalEnd);
241+
};
242+
243+
const findArgsType = (root, pascalFieldName) => {
244+
const prefixes = new Set([
245+
`${root}${pascalFieldName}Args`,
246+
`${root}${pascalFieldName.replace(/IOS/g, 'Ios')}Args`,
247+
`${root}${pascalFieldName.replace(/Ios/g, 'IOS')}Args`,
248+
]);
249+
for (const name of prefixes) {
250+
if (content.includes(`export interface ${name} {`)) {
251+
return name;
252+
}
253+
}
254+
return 'never';
255+
};
256+
257+
const buildRootHelpers = (root) => {
258+
const rootMatch = content.match(new RegExp(`export interface ${root} {\n([\\s\\S]*?)\n}\n`));
259+
if (!rootMatch) return '';
260+
const body = rootMatch[1];
261+
const fieldPattern = /^\s*([A-Za-z0-9_]+)\??:\s*[^;]+;$/gm;
262+
const entries = [];
263+
let fieldMatch;
264+
while ((fieldMatch = fieldPattern.exec(body)) !== null) {
265+
const fieldName = fieldMatch[1];
266+
const pascal = fieldName[0].toUpperCase() + fieldName.slice(1);
267+
const argsType = findArgsType(root, pascal);
268+
entries.push({ fieldName, argsType });
269+
}
270+
if (entries.length === 0) return '';
271+
const { start, end } = helperMarkers(root);
272+
const mapName = `${root}ArgsMap`;
273+
const fieldAlias = `${root}Field`;
274+
const mapAlias = `${root}FieldMap`;
275+
const lines = [];
276+
lines.push(start);
277+
lines.push(`export type ${mapName} = {`);
278+
for (const { fieldName, argsType } of entries) {
279+
lines.push(` ${fieldName}: ${argsType};`);
280+
}
281+
lines.push('};');
282+
lines.push('');
283+
lines.push(`export type ${fieldAlias}<K extends keyof ${root}> =`);
284+
lines.push(` ${mapName}[K] extends never`);
285+
lines.push(` ? () => NonNullable<${root}[K]>`);
286+
lines.push(` : (args: ${mapName}[K]) => NonNullable<${root}[K]>;`);
287+
lines.push('');
288+
lines.push(`export type ${mapAlias} = {`);
289+
lines.push(` [K in keyof ${root}]?: ${fieldAlias}<K>;`);
290+
lines.push('};');
291+
lines.push(end);
292+
lines.push('');
293+
return lines.join('\n');
294+
};
295+
296+
const helperBlocks = [];
297+
for (const root of ROOT_DEFINITIONS) {
298+
removeRootHelpers(root);
299+
const block = buildRootHelpers(root);
300+
if (block) helperBlocks.push(block);
301+
}
302+
303+
if (helperBlocks.length > 0) {
304+
if (!content.endsWith('\n')) {
305+
content += '\n';
306+
}
307+
content += helperBlocks.join('\n');
308+
}
309+
225310
writeFileSync(targetPath, content);

scripts/generate-dart-types.mjs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,50 @@ const printOperationInterface = (operationType) => {
608608
lines.push('}', '');
609609
};
610610

611+
const printOperationHelpers = (operationType) => {
612+
const rootName = operationType.name;
613+
const fields = Object.values(operationType.getFields())
614+
.filter((field) => field.name !== '_placeholder')
615+
.sort((a, b) => a.name.localeCompare(b.name));
616+
if (fields.length === 0) return;
617+
618+
lines.push(`// MARK: - ${rootName} Helpers`, '');
619+
620+
fields.forEach((field) => {
621+
const pascalField = toPascalCase(field.name);
622+
const aliasName = `${rootName}${pascalField}Handler`;
623+
const { type, nullable } = getDartType(field.type);
624+
const returnType = `${type}${nullable ? '?' : ''}`;
625+
if (field.args.length === 0) {
626+
lines.push(`typedef ${aliasName} = Future<${returnType}> Function();`);
627+
return;
628+
}
629+
lines.push(`typedef ${aliasName} = Future<${returnType}> Function({`);
630+
field.args.forEach((arg) => {
631+
const { type: argType, nullable: argNullable } = getDartType(arg.type);
632+
const finalType = `${argType}${argNullable ? '?' : ''}`;
633+
const prefix = argNullable ? '' : 'required ';
634+
lines.push(` ${prefix}${finalType} ${escapeDartName(arg.name)},`);
635+
});
636+
lines.push('});');
637+
});
638+
639+
const helperClass = `${rootName}Handlers`;
640+
lines.push('', `class ${helperClass} {`);
641+
lines.push(` const ${helperClass}({`);
642+
fields.forEach((field) => {
643+
lines.push(` this.${escapeDartName(field.name)},`);
644+
});
645+
lines.push(' });', '');
646+
fields.forEach((field) => {
647+
const pascalField = toPascalCase(field.name);
648+
const aliasName = `${rootName}${pascalField}Handler`;
649+
const propertyName = escapeDartName(field.name);
650+
lines.push(` final ${aliasName}? ${propertyName};`);
651+
});
652+
lines.push('}', '');
653+
};
654+
611655
if (enums.length) {
612656
lines.push('// MARK: - Enums', '');
613657
enums.sort((a, b) => a.name.localeCompare(b.name)).forEach(printEnum);
@@ -638,6 +682,11 @@ if (operationTypes.length) {
638682
operationTypes.sort((a, b) => a.name.localeCompare(b.name)).forEach(printOperationInterface);
639683
}
640684

685+
if (operationTypes.length) {
686+
lines.push('// MARK: - Root Operation Helpers', '');
687+
operationTypes.sort((a, b) => a.name.localeCompare(b.name)).forEach(printOperationHelpers);
688+
}
689+
641690
const outputPath = resolve(__dirname, '../src/generated/types.dart');
642691
mkdirSync(dirname(outputPath), { recursive: true });
643692
writeFileSync(outputPath, lines.join('\n'));

scripts/generate-kotlin-types.mjs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,42 @@ const printOperationInterface = (operationType) => {
538538
lines.push('}', '');
539539
};
540540

541+
const printOperationHelpers = (operationType) => {
542+
const rootName = operationType.name;
543+
const fields = Object.values(operationType.getFields())
544+
.filter((field) => field.name !== '_placeholder')
545+
.sort((a, b) => a.name.localeCompare(b.name));
546+
if (fields.length === 0) return;
547+
548+
lines.push(`// MARK: - ${rootName} Helpers`, '');
549+
550+
fields.forEach((field) => {
551+
const aliasName = `${rootName}${capitalize(field.name)}Handler`;
552+
const { type, nullable } = getKotlinType(field.type);
553+
const returnType = type + (nullable ? '?' : '');
554+
if (field.args.length === 0) {
555+
lines.push(`public typealias ${aliasName} = suspend () -> ${returnType}`);
556+
return;
557+
}
558+
const argsSignature = field.args.map((arg) => {
559+
const { type: argType, nullable: argNullable } = getKotlinType(arg.type);
560+
const argumentType = argType + (argNullable ? '?' : '');
561+
return `${escapeKotlinName(arg.name)}: ${argumentType}`;
562+
}).join(', ');
563+
lines.push(`public typealias ${aliasName} = suspend (${argsSignature}) -> ${returnType}`);
564+
});
565+
566+
const helperClass = `${rootName}Handlers`;
567+
lines.push('', `public data class ${helperClass}(`);
568+
fields.forEach((field, index) => {
569+
const aliasName = `${rootName}${capitalize(field.name)}Handler`;
570+
const propertyName = escapeKotlinName(field.name);
571+
const suffix = index === fields.length - 1 ? '' : ',';
572+
lines.push(` val ${propertyName}: ${aliasName}? = null${suffix}`);
573+
});
574+
lines.push(')', '');
575+
};
576+
541577
if (enums.length) {
542578
lines.push('// MARK: - Enums', '');
543579
enums.sort((a, b) => a.name.localeCompare(b.name)).forEach(printEnum);
@@ -568,6 +604,11 @@ if (operationTypes.length) {
568604
operationTypes.sort((a, b) => a.name.localeCompare(b.name)).forEach(printOperationInterface);
569605
}
570606

607+
if (operationTypes.length) {
608+
lines.push('// MARK: - Root Operation Helpers', '');
609+
operationTypes.sort((a, b) => a.name.localeCompare(b.name)).forEach(printOperationHelpers);
610+
}
611+
571612
const outputPath = resolve(__dirname, '../src/generated/Types.kt');
572613
mkdirSync(dirname(outputPath), { recursive: true });
573614
writeFileSync(outputPath, lines.join('\n'));

scripts/generate-swift-types.mjs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ const lowerCamelCase = (value) => {
111111
return parts[0] + parts.slice(1).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)).join('');
112112
};
113113

114+
const capitalize = (value) => (value.length === 0 ? value : value.charAt(0).toUpperCase() + value.slice(1));
115+
114116
const toConstantCase = (value) => value
115117
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
116118
.replace(/([A-Z])([A-Z][a-z])/g, '$1_$2')
@@ -431,6 +433,51 @@ const printOperationProtocol = (operationType) => {
431433
lines.push('}', '');
432434
};
433435

436+
const printOperationHelpers = (operationType) => {
437+
const rootName = operationType.name;
438+
const fields = Object.values(operationType.getFields())
439+
.filter((field) => field.name !== '_placeholder')
440+
.sort((a, b) => a.name.localeCompare(b.name));
441+
if (fields.length === 0) return;
442+
443+
lines.push(`// MARK: - ${rootName} Helpers`, '');
444+
445+
fields.forEach((field) => {
446+
const aliasName = `${rootName}${capitalize(field.name)}Handler`;
447+
const { type, optional } = swiftTypeFor(field.type);
448+
const returnType = type + (optional ? '?' : '');
449+
if (field.args.length === 0) {
450+
lines.push(`public typealias ${aliasName} = () async throws -> ${returnType}`);
451+
return;
452+
}
453+
const params = field.args.map((arg) => {
454+
const { type: argType, optional: argOptional } = swiftTypeFor(arg.type);
455+
const finalType = argType + (argOptional ? '?' : '');
456+
return `_ ${escapeSwiftName(arg.name)}: ${finalType}`;
457+
}).join(', ');
458+
lines.push(`public typealias ${aliasName} = (${params}) async throws -> ${returnType}`);
459+
});
460+
461+
const structName = `${rootName}Handlers`;
462+
lines.push('', `public struct ${structName} {`);
463+
fields.forEach((field) => {
464+
const aliasName = `${rootName}${capitalize(field.name)}Handler`;
465+
lines.push(` public var ${escapeSwiftName(field.name)}: ${aliasName}?`);
466+
});
467+
lines.push('');
468+
const initParams = fields.map((field) => {
469+
const aliasName = `${rootName}${capitalize(field.name)}Handler`;
470+
return `${escapeSwiftName(field.name)}: ${aliasName}? = nil`;
471+
}).join(',\n ');
472+
lines.push(' public init(' + (fields.length ? `\n ${initParams}\n ` : '') + ') {');
473+
fields.forEach((field) => {
474+
const propertyName = escapeSwiftName(field.name);
475+
lines.push(` self.${propertyName} = ${propertyName}`);
476+
});
477+
lines.push(' }');
478+
lines.push('}', '');
479+
};
480+
434481
if (enums.length) {
435482
lines.push('// MARK: - Enums', '');
436483
enums.sort((a, b) => a.name.localeCompare(b.name)).forEach(printEnum);
@@ -461,6 +508,11 @@ if (operations.length) {
461508
operations.sort((a, b) => a.name.localeCompare(b.name)).forEach(printOperationProtocol);
462509
}
463510

511+
if (operations.length) {
512+
lines.push('// MARK: - Root Operation Helpers', '');
513+
operations.sort((a, b) => a.name.localeCompare(b.name)).forEach(printOperationHelpers);
514+
}
515+
464516
const outputPath = resolve(__dirname, '../src/generated/Types.swift');
465517
mkdirSync(dirname(outputPath), { recursive: true });
466518
writeFileSync(outputPath, lines.join('\n'));

src/generated/Types.kt

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2080,3 +2080,89 @@ public interface SubscriptionResolver {
20802080
*/
20812081
suspend fun purchaseUpdated(): Purchase
20822082
}
2083+
2084+
// MARK: - Root Operation Helpers
2085+
2086+
// MARK: - Mutation Helpers
2087+
2088+
public typealias MutationAcknowledgePurchaseAndroidHandler = suspend (purchaseToken: String) -> VoidResult
2089+
public typealias MutationBeginRefundRequestIOSHandler = suspend (sku: String) -> RefundResultIOS
2090+
public typealias MutationClearTransactionIOSHandler = suspend () -> VoidResult
2091+
public typealias MutationConsumePurchaseAndroidHandler = suspend (purchaseToken: String) -> VoidResult
2092+
public typealias MutationDeepLinkToSubscriptionsHandler = suspend (options: DeepLinkOptions?) -> VoidResult
2093+
public typealias MutationEndConnectionHandler = suspend () -> Boolean
2094+
public typealias MutationFinishTransactionHandler = suspend (purchase: PurchaseInput, isConsumable: Boolean?) -> VoidResult
2095+
public typealias MutationInitConnectionHandler = suspend () -> Boolean
2096+
public typealias MutationPresentCodeRedemptionSheetIOSHandler = suspend () -> VoidResult
2097+
public typealias MutationRequestPurchaseHandler = suspend (params: RequestPurchaseProps) -> RequestPurchaseResult?
2098+
public typealias MutationRequestPurchaseOnPromotedProductIOSHandler = suspend () -> PurchaseIOS
2099+
public typealias MutationRestorePurchasesHandler = suspend () -> VoidResult
2100+
public typealias MutationShowManageSubscriptionsIOSHandler = suspend () -> List<PurchaseIOS>
2101+
public typealias MutationSyncIOSHandler = suspend () -> VoidResult
2102+
public typealias MutationValidateReceiptHandler = suspend (options: ReceiptValidationProps) -> ReceiptValidationResult
2103+
2104+
public data class MutationHandlers(
2105+
val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler? = null,
2106+
val beginRefundRequestIOS: MutationBeginRefundRequestIOSHandler? = null,
2107+
val clearTransactionIOS: MutationClearTransactionIOSHandler? = null,
2108+
val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler? = null,
2109+
val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler? = null,
2110+
val endConnection: MutationEndConnectionHandler? = null,
2111+
val finishTransaction: MutationFinishTransactionHandler? = null,
2112+
val initConnection: MutationInitConnectionHandler? = null,
2113+
val presentCodeRedemptionSheetIOS: MutationPresentCodeRedemptionSheetIOSHandler? = null,
2114+
val requestPurchase: MutationRequestPurchaseHandler? = null,
2115+
val requestPurchaseOnPromotedProductIOS: MutationRequestPurchaseOnPromotedProductIOSHandler? = null,
2116+
val restorePurchases: MutationRestorePurchasesHandler? = null,
2117+
val showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? = null,
2118+
val syncIOS: MutationSyncIOSHandler? = null,
2119+
val validateReceipt: MutationValidateReceiptHandler? = null
2120+
)
2121+
2122+
// MARK: - Query Helpers
2123+
2124+
public typealias QueryCurrentEntitlementIOSHandler = suspend (skus: List<String>?) -> List<EntitlementIOS>
2125+
public typealias QueryFetchProductsHandler = suspend (params: ProductRequest) -> FetchProductsResult
2126+
public typealias QueryGetActiveSubscriptionsHandler = suspend (subscriptionIds: List<String>?) -> List<ActiveSubscription>
2127+
public typealias QueryGetAppTransactionIOSHandler = suspend () -> AppTransaction?
2128+
public typealias QueryGetAvailablePurchasesHandler = suspend (options: PurchaseOptions?) -> List<Purchase>
2129+
public typealias QueryGetPendingTransactionsIOSHandler = suspend () -> List<PurchaseIOS>
2130+
public typealias QueryGetPromotedProductIOSHandler = suspend () -> ProductIOS?
2131+
public typealias QueryGetReceiptDataIOSHandler = suspend () -> String
2132+
public typealias QueryGetStorefrontIOSHandler = suspend () -> String
2133+
public typealias QueryGetTransactionJwsIOSHandler = suspend (transactionId: String) -> String
2134+
public typealias QueryHasActiveSubscriptionsHandler = suspend (subscriptionIds: List<String>?) -> Boolean
2135+
public typealias QueryIsEligibleForIntroOfferIOSHandler = suspend (productIds: List<String>) -> Boolean
2136+
public typealias QueryIsTransactionVerifiedIOSHandler = suspend (transactionId: String) -> Boolean
2137+
public typealias QueryLatestTransactionIOSHandler = suspend (sku: String) -> PurchaseIOS?
2138+
public typealias QuerySubscriptionStatusIOSHandler = suspend (skus: List<String>?) -> List<SubscriptionStatusIOS>
2139+
2140+
public data class QueryHandlers(
2141+
val currentEntitlementIOS: QueryCurrentEntitlementIOSHandler? = null,
2142+
val fetchProducts: QueryFetchProductsHandler? = null,
2143+
val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler? = null,
2144+
val getAppTransactionIOS: QueryGetAppTransactionIOSHandler? = null,
2145+
val getAvailablePurchases: QueryGetAvailablePurchasesHandler? = null,
2146+
val getPendingTransactionsIOS: QueryGetPendingTransactionsIOSHandler? = null,
2147+
val getPromotedProductIOS: QueryGetPromotedProductIOSHandler? = null,
2148+
val getReceiptDataIOS: QueryGetReceiptDataIOSHandler? = null,
2149+
val getStorefrontIOS: QueryGetStorefrontIOSHandler? = null,
2150+
val getTransactionJwsIOS: QueryGetTransactionJwsIOSHandler? = null,
2151+
val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler? = null,
2152+
val isEligibleForIntroOfferIOS: QueryIsEligibleForIntroOfferIOSHandler? = null,
2153+
val isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = null,
2154+
val latestTransactionIOS: QueryLatestTransactionIOSHandler? = null,
2155+
val subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = null
2156+
)
2157+
2158+
// MARK: - Subscription Helpers
2159+
2160+
public typealias SubscriptionPromotedProductIOSHandler = suspend () -> String
2161+
public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError
2162+
public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase
2163+
2164+
public data class SubscriptionHandlers(
2165+
val promotedProductIOS: SubscriptionPromotedProductIOSHandler? = null,
2166+
val purchaseError: SubscriptionPurchaseErrorHandler? = null,
2167+
val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null
2168+
)

0 commit comments

Comments
 (0)