@contractkit/plugin-typescript 0.17.2 → 0.17.3
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.
- package/.turbo/turbo-build$colon$ci.log +6 -6
- package/.turbo/turbo-test$colon$ci.log +13 -13
- package/CHANGELOG.md +6 -0
- package/coverage/clover.xml +541 -522
- package/coverage/coverage-final.json +2 -2
- package/coverage/index.html +18 -18
- package/coverage/src/codegen-contract.ts.html +350 -218
- package/coverage/src/codegen-operation.ts.html +1 -1
- package/coverage/src/codegen-plain-types.ts.html +1 -1
- package/coverage/src/codegen-sdk.ts.html +1 -1
- package/coverage/src/index.html +18 -18
- package/coverage/src/index.ts.html +1 -1
- package/coverage/src/path-utils.ts.html +1 -1
- package/coverage/src/ts-render.ts.html +1 -1
- package/coverage/tests/helpers.ts.html +6 -6
- package/coverage/tests/index.html +1 -1
- package/dist/codegen-contract.d.ts +5 -0
- package/dist/codegen-contract.d.ts.map +1 -1
- package/dist/index.js +33 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/codegen-contract.ts +47 -3
- package/tests/codegen-contract.test.ts +26 -0
package/package.json
CHANGED
package/src/codegen-contract.ts
CHANGED
|
@@ -40,6 +40,7 @@ export function modeToWrapper(mode: ObjectMode): string {
|
|
|
40
40
|
|
|
41
41
|
// ─── Cross-file import resolution ─────────────────────────────────────────
|
|
42
42
|
|
|
43
|
+
/** Cross-file context passed to `generateContract` to wire up imports and Input/Output variant tracking. */
|
|
43
44
|
export interface ContractCodegenContext {
|
|
44
45
|
/** Map from model name → absolute output file path */
|
|
45
46
|
modelOutPaths: Map<string, string>;
|
|
@@ -250,7 +251,7 @@ function generateModel(
|
|
|
250
251
|
const needsInputSplit = effective.fields.some(f => f.visibility !== 'normal') || (modelsWithInput?.has(effective.name) ?? false);
|
|
251
252
|
|
|
252
253
|
const lines = needsInputSplit
|
|
253
|
-
? generateThreeSchemaModel(effective, outPath, modelsWithInput, modelsWithWriteonly)
|
|
254
|
+
? generateThreeSchemaModel(effective, outPath, modelsWithInput, modelsWithWriteonly, modelMap)
|
|
254
255
|
: generateSimpleModel(effective, outPath);
|
|
255
256
|
|
|
256
257
|
// Emit Output type alias when this model (transitively) has format(output=...)
|
|
@@ -342,7 +343,27 @@ function buildExtendChain(bases: string[], resolveName: (b: string) => string):
|
|
|
342
343
|
return { head, tail };
|
|
343
344
|
}
|
|
344
345
|
|
|
345
|
-
function
|
|
346
|
+
function collectEffectiveWritableFieldNames(modelName: string, modelMap: Map<string, ModelNode>): Set<string> {
|
|
347
|
+
const model = modelMap.get(modelName);
|
|
348
|
+
if (!model || model.type) return new Set();
|
|
349
|
+
const result = new Set<string>();
|
|
350
|
+
for (const base of model.bases ?? []) {
|
|
351
|
+
for (const f of collectEffectiveWritableFieldNames(base, modelMap)) result.add(f);
|
|
352
|
+
}
|
|
353
|
+
for (const field of model.fields) {
|
|
354
|
+
if (field.visibility === 'readonly') result.delete(field.name);
|
|
355
|
+
else result.add(field.name);
|
|
356
|
+
}
|
|
357
|
+
return result;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function generateThreeSchemaModel(
|
|
361
|
+
model: ModelNode,
|
|
362
|
+
outPath?: string,
|
|
363
|
+
modelsWithInput?: Set<string>,
|
|
364
|
+
modelsWithWriteonly?: Set<string>,
|
|
365
|
+
modelMap?: Map<string, ModelNode>,
|
|
366
|
+
): string[] {
|
|
346
367
|
const lines: string[] = [];
|
|
347
368
|
const name = model.name;
|
|
348
369
|
|
|
@@ -388,9 +409,28 @@ function generateThreeSchemaModel(model: ModelNode, outPath?: string, modelsWith
|
|
|
388
409
|
// extends ParentInput if parent has an Input variant, else extends parent read schema
|
|
389
410
|
const writeFields = allFields.filter(f => f.visibility !== 'readonly');
|
|
390
411
|
const writeBody = modelsWithInput ? renderInputFields(writeFields, modelsWithInput, model.mode) : renderFields(writeFields, model.mode);
|
|
412
|
+
// Fields that become readonly in this model but were writable in a base must be omitted from
|
|
413
|
+
// the base Input schema — Zod's .extend() cannot remove inherited fields.
|
|
414
|
+
const fieldsToOmit = new Set<string>();
|
|
415
|
+
if (bases.length > 0 && modelMap) {
|
|
416
|
+
for (const field of allFields) {
|
|
417
|
+
if (field.visibility === 'readonly') {
|
|
418
|
+
for (const base of bases) {
|
|
419
|
+
if (collectEffectiveWritableFieldNames(base, modelMap).has(field.name)) {
|
|
420
|
+
fieldsToOmit.add(field.name);
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const omitClause =
|
|
428
|
+
fieldsToOmit.size > 0
|
|
429
|
+
? `.omit({ ${[...fieldsToOmit].map(f => `${quoteKey(f)}: true`).join(', ')} })`
|
|
430
|
+
: '';
|
|
391
431
|
if (bases.length > 0) {
|
|
392
432
|
const { head, tail } = buildExtendChain(bases, b => (modelsWithInput?.has(b) ? `${b}Input` : b));
|
|
393
|
-
lines.push(`export const ${name}Input = ${head}${tail}.extend({`);
|
|
433
|
+
lines.push(`export const ${name}Input = ${head}${tail}${omitClause}.extend({`);
|
|
394
434
|
} else {
|
|
395
435
|
lines.push(`export const ${name}Input = ${wrapper}({`);
|
|
396
436
|
}
|
|
@@ -907,6 +947,7 @@ function rootNeedsDateTime(root: ContractRootNode): boolean {
|
|
|
907
947
|
return root.models.some(m => (m.type && typeNeedsDateTime(m.type)) || m.fields.some(f => typeNeedsDateTime(f.type)));
|
|
908
948
|
}
|
|
909
949
|
|
|
950
|
+
/** Returns true if `type` (recursively) contains a scalar with the given `name`. */
|
|
910
951
|
export function typeNeedsScalar(type: ContractTypeNode, name: string): boolean {
|
|
911
952
|
switch (type.kind) {
|
|
912
953
|
case 'scalar':
|
|
@@ -932,10 +973,12 @@ export function typeNeedsScalar(type: ContractTypeNode, name: string): boolean {
|
|
|
932
973
|
}
|
|
933
974
|
}
|
|
934
975
|
|
|
976
|
+
/** Returns true if any model in `root` uses a scalar with the given `name`. */
|
|
935
977
|
export function rootNeedsScalar(root: ContractRootNode, name: string): boolean {
|
|
936
978
|
return root.models.some(m => (m.type && typeNeedsScalar(m.type, name)) || m.fields.some(f => typeNeedsScalar(f.type, name)));
|
|
937
979
|
}
|
|
938
980
|
|
|
981
|
+
/** Returns true if `type` (recursively) contains a `date`, `time`, or `datetime` scalar. */
|
|
939
982
|
export function typeNeedsDateTime(type: ContractTypeNode): boolean {
|
|
940
983
|
switch (type.kind) {
|
|
941
984
|
case 'scalar':
|
|
@@ -955,6 +998,7 @@ export function typeNeedsDateTime(type: ContractTypeNode): boolean {
|
|
|
955
998
|
}
|
|
956
999
|
}
|
|
957
1000
|
|
|
1001
|
+
/** Collect model names referenced in `root` that are not defined locally (need to be imported). */
|
|
958
1002
|
export function collectExternalRefs(root: ContractRootNode): string[] {
|
|
959
1003
|
const localNames = new Set(root.models.map(m => m.name));
|
|
960
1004
|
const refs = new Set<string>();
|
|
@@ -657,6 +657,32 @@ describe('generateContract', () => {
|
|
|
657
657
|
// A has Input variant (because of readonly), B doesn't.
|
|
658
658
|
expect(output).toContain('export const TestInput = AInput.extend(B.shape).extend({');
|
|
659
659
|
});
|
|
660
|
+
|
|
661
|
+
it('omits override-readonly field from child Input schema', () => {
|
|
662
|
+
const root = contractRoot([
|
|
663
|
+
model('Base', [field('id', scalarType('uuid')), field('name', scalarType('string'))]),
|
|
664
|
+
model('Child', [field('name', scalarType('string'), { visibility: 'readonly', override: true })], {
|
|
665
|
+
bases: ['Base'],
|
|
666
|
+
}),
|
|
667
|
+
]);
|
|
668
|
+
const output = generateContract(root);
|
|
669
|
+
// Base has no visibility — no BaseInput
|
|
670
|
+
expect(output).not.toContain('BaseInput');
|
|
671
|
+
// Child re-declares 'name' as readonly, so 'name' must be omitted from Base before extending
|
|
672
|
+
expect(output).toContain('export const ChildInput = Base.omit({ name: true }).extend({');
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('omits override-readonly field inherited transitively through multiple bases', () => {
|
|
676
|
+
const root = contractRoot([
|
|
677
|
+
model('Root', [field('legalId', scalarType('uuid'))]),
|
|
678
|
+
model('Mid', [], { bases: ['Root'] }),
|
|
679
|
+
model('Child', [field('legalId', scalarType('uuid'), { visibility: 'readonly', override: true })], {
|
|
680
|
+
bases: ['Mid'],
|
|
681
|
+
}),
|
|
682
|
+
]);
|
|
683
|
+
const output = generateContract(root);
|
|
684
|
+
expect(output).toContain('export const ChildInput = Mid.omit({ legalId: true }).extend({');
|
|
685
|
+
});
|
|
660
686
|
});
|
|
661
687
|
|
|
662
688
|
// ─── Type alias Input variants ────────────────────────────────
|